From 622942482cf79818396e2db38f6e7ea717b4a7eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 00:30:17 -0500 Subject: [PATCH 001/470] [rp2] Size the lwIP segment pool and heap for concurrent senders (#18257) --- esphome/components/api/api_frame_helper.h | 4 +- esphome/components/rp2/__init__.py | 150 ++++++++++++++-------- esphome/components/rp2/lwipopts.h.jinja | 13 +- tests/unit_tests/components/test_rp2.py | 107 +++++++++++++-- 4 files changed, 207 insertions(+), 67 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9cae6ba92e..9c49956bbd 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -149,7 +149,7 @@ class APIFrameHelper { // holding data too long waiting for Nagle's timer causes buffer exhaustion // and dropped messages. // - // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) // // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) @@ -312,7 +312,7 @@ class APIFrameHelper { // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. - // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. + // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more. #ifdef USE_ESP8266 static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 3bc2df7a61..87e78003ed 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -388,6 +388,74 @@ async def to_code(config): _configure_lwip() +# --- lwIP sizing. See _configure_lwip() for the platform comparison table. --- + +# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. +# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. +LWIP_TCP_SND_BUF = "(4*TCP_MSS)" + +# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. +LWIP_TCP_WND = "(4*TCP_MSS)" + +# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer +# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS +# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 +LWIP_TCP_SND_QUEUELEN = 17 + +# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB +# queue length — lwIP's sanity check only demands >=, the floor for a single +# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured +# at 20 bytes per entry, so under 700 bytes total. +LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN + +# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. +# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path +# copies into PBUF_RAM out of MEM_SIZE. +LWIP_PBUF_POOL_SIZE = 16 + +# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing +# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full +# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 + +# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB. +# +# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c +# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75% +# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well +# before the total does — hence the intermittent failures. With rp2's +# max_connections of 4, a third sender has nothing left. +# +# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards). +# Must stay under 64000 or lwIP widens mem_size_t to u32_t. +LWIP_MEM_SIZE = 32768 + + +def build_lwip_defines( + tcp_sockets: int, udp_sockets: int, listening_tcp: int +) -> dict[str, str]: + """Render the lwIP override values for the Jinja2 template. + + The template uses #include_next to chain to the framework's original + lwipopts.h, then #undef/#define only these. Split out from + _configure_lwip() so the values that actually reach the generated header + can be checked without standing up CORE. + + Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The + static pools are the only IRQ-safe allocator on this platform, so the fix + is to size them correctly rather than to make them dynamic. + """ + return { + "TCP_SND_BUF": LWIP_TCP_SND_BUF, + "TCP_WND": LWIP_TCP_WND, + "TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN), + "MEM_SIZE": str(LWIP_MEM_SIZE), + "MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG), + "PBUF_POOL_SIZE": str(LWIP_PBUF_POOL_SIZE), + "MEMP_NUM_TCP_PCB": str(tcp_sockets), + "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), + "MEMP_NUM_UDP_PCB": str(udp_sockets), + } + + def _configure_lwip() -> None: """Configure lwIP options for RP2040 by generating a custom lwipopts.h. @@ -407,25 +475,36 @@ def _configure_lwip() -> None: ──────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + TCP_SND_QUEUELEN ~8 17 32 17 MEM_LIBC_MALLOC 1 1 0 0* MEMP_MEM_MALLOC 1 1 0 0** - MEM_SIZE N/A*** N/A*** 16KB 16KB + MEM_SIZE N/A*** N/A*** 16KB 32KB PBUF_POOL_SIZE 10 16 24 16 - MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_SEG 10 16 32 34**** MEMP_NUM_TCP_PCB 5 16 5 dynamic - MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic + MEMP_NUM_TCP_PCB_LISTEN 4 16 8***** dynamic MEMP_NUM_UDP_PCB 4 16 7 dynamic - TCP_SND_QUEUELEN ~8 17 32 17 * MEM_LIBC_MALLOC must stay 0: arduino-pico uses PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from a low-priority pendsv IRQ. The pico-sdk explicitly blocks MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ). - ** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB) - is too small to hold all pools dynamically. The PBUF_POOL alone needs - ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings. - *** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). - **** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. + ** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc() + pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes + its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0), + so under NO_SYS=1 mem_malloc()/mem_free() are unprotected — and memp.c + calls mem_malloc() outside the guard anyway. RX pbufs would then be + allocated from the pendsv IRQ on the same unguarded free list the main + loop uses for tcp_write(). Tried on hardware: faults within seconds on + CYW43. Ethernet survives only because it polls from the main loop. + *** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from + the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps + (MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are + 0 here, so ours are hard limits; don't copy their numbers. + **** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so + sizing it to the per-PCB value lets one busy connection drain it for + every other. 2× covers two PCBs; MEM_SIZE is the real limit past that. + ***** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. "dynamic" = auto-calculated from component socket registrations via socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN. """ @@ -444,48 +523,7 @@ def _configure_lwip() -> None: # UDP PCBs (2) are absorbed by the generous minimum of 6. listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen) - # TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. - # ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. - tcp_snd_buf = "(4*TCP_MSS)" - - # TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. - tcp_wnd = "(4*TCP_MSS)" - - # TCP_SND_QUEUELEN: max pbufs queued for send buffer - # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS - # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 - tcp_snd_queuelen = 17 - # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) - memp_num_tcp_seg = tcp_snd_queuelen - - # PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. - # 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1, - # this is a max count (allocated on demand from heap). - pbuf_pool_size = 16 - - # Build the lwIP override defines for the Jinja2 template. - # The template uses #include_next to chain to the framework's original - # lwipopts.h, then #undef/#define only the values we need to change. - # - # Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp - # allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE - # is too small to hold all pools dynamically under stress. The PBUF_POOL - # alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate - # the BSS savings. - # - # MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses - # PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from - # a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe. - lwip_defines: dict[str, str] = { - "TCP_SND_BUF": tcp_snd_buf, - "TCP_WND": tcp_wnd, - "TCP_SND_QUEUELEN": str(tcp_snd_queuelen), - "MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg), - "PBUF_POOL_SIZE": str(pbuf_pool_size), - "MEMP_NUM_TCP_PCB": str(tcp_sockets), - "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), - "MEMP_NUM_UDP_PCB": str(udp_sockets), - } + lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp) # Store for copy_files() to generate the header CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines @@ -500,7 +538,8 @@ def _configure_lwip() -> None: udp_min = " (min)" if udp_sockets > sc.udp else "" listen_min = " (min)" if listening_tcp > sc.tcp_listen else "" _LOGGER.info( - "Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + "Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + LWIP_MEM_SIZE, tcp_sockets, tcp_min, sc.tcp_details, @@ -521,7 +560,7 @@ 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 + from jinja2 import Environment, StrictUndefined lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: @@ -534,7 +573,10 @@ def _generate_lwipopts_h() -> None: template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( encoding="utf-8" ) - jinja_env = Environment(keep_trailing_newline=True) + # StrictUndefined: a placeholder with no value would otherwise render + # empty, emitting a bare #define that compiles and silently means + # something else in lwIP's config. + jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined) template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) diff --git a/esphome/components/rp2/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja index 36d7d4da14..2da4f467a9 100644 --- a/esphome/components/rp2/lwipopts.h.jinja +++ b/esphome/components/rp2/lwipopts.h.jinja @@ -20,13 +20,24 @@ #undef TCP_WND #define TCP_WND {{ TCP_WND }} -// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32 #undef TCP_SND_QUEUELEN #define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} +// Segment pool: global across every PCB, so it is sized above the per-PCB +// queue length rather than equal to it. lwIP's sanity check only requires +// >= TCP_SND_QUEUELEN, which is the floor for a single connection. #undef MEMP_NUM_TCP_SEG #define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} +// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into. +// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB +// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at +// 75%, and mem.c is first-fit, so the largest contiguous run ran out well +// before the total did. +#undef MEM_SIZE +#define MEM_SIZE {{ MEM_SIZE }} + // Packet buffer pool: 16 matches ESP32 (down from 24) #undef PBUF_POOL_SIZE #define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py index 023d926dc4..cd92bc24fa 100644 --- a/tests/unit_tests/components/test_rp2.py +++ b/tests/unit_tests/components/test_rp2.py @@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered by the framework tests under ``tests/unit_tests/``. """ +from pathlib import Path +import re + +from esphome.components import rp2 + def test_board_id_has_wifi_for_known_wifi_board() -> None: """``rpipicow`` is the canonical Pico W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipicow") is True def test_board_id_has_wifi_for_known_non_wifi_board() -> None: """Plain ``rpipico`` has no CYW43 → False.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico") is False def test_board_id_has_wifi_for_rp2350_w_variant() -> None: """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico2w") is True @@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: block and any genuinely-unsupported config trips the existing "no CYW43" guard at compile time. """ - from esphome.components import rp2 - assert rp2.board_id_has_wifi("not-a-real-board-id") is True @@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None: opts in via ``ALIASES``; without this declaration the rename framework wouldn't route legacy configs. """ - from esphome.components import rp2 - assert "rp2040" in rp2.ALIASES assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" @@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: assert rp2040_boards is rp2_boards assert rp2040_generate is rp2_generate + + +def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None: + """The segment pool is global while the send queue is per-PCB. + + lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``, + which is the floor for a *single* connection: at equality one busy PCB can + drain the pool for every other PCB. Dropping back to that floor would + rebuild the starvation this sizing exists to prevent, and nothing in the + build would complain. + """ + assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN + + +def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None: + """``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on + ``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the + heap past that bound is a real option, but it should be a deliberate one + rather than a side effect of tuning. + """ + assert rp2.LWIP_MEM_SIZE <= 64000 + + +def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None: + """Pin the floor as well as the ceiling. + + The ceiling above is satisfied by arduino-pico's own 16 KB, which is the + value this change exists to move off, so on its own it would let a revert + through. Derive the floor from the sizing comment on the constant: with + TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block + (pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB), + a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's + max_connections on rp2 is 4. Room for three concurrent senders is the + minimum that makes the change worth making; 16 KB does not reach it. + """ + segments_per_full_send_buf = 4 + bytes_per_mss_block = 1536 + concurrent_senders = 3 + + assert ( + concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block + <= rp2.LWIP_MEM_SIZE + ) + + +def test_lwip_defines_carry_the_sizing_into_the_header() -> None: + """The constants above only matter if they reach the generated header. + + ``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it + rather than on the constants alone: dropping a key here would silently + fall back to arduino-pico's own value while every other assertion in this + file stayed green. + """ + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + + assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE) + assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG) + assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN) + # Socket-derived counts pass through untouched. + assert defines["MEMP_NUM_TCP_PCB"] == "8" + assert defines["MEMP_NUM_UDP_PCB"] == "6" + assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2" + + +def test_lwipopts_template_renders_every_sizing_value() -> None: + """Render the template the way _generate_lwipopts_h() does and check the + header that actually ships. + + Covers both directions. A ``#define`` block deleted from the template + leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB + heap this change exists to move off, and the loop below catches that. A + placeholder with no dict key would otherwise render empty and emit a bare + ``#define FOO``; StrictUndefined turns that into an error instead. + Matching on text also survives a filter or conditional appearing in the + template later, which a placeholder regex would not. + """ + from jinja2 import Environment, StrictUndefined + + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" + ) + rendered = ( + Environment(keep_trailing_newline=True, undefined=StrictUndefined) + .from_string(template_text) + .render(**defines) + ) + + for name, value in defines.items(): + assert re.search( + rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE + ), f"{name} did not reach the generated header as {value!r}" From 25c0c2c97b1a9ff45b7213c048d3b12dc6720d39 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Aug 2026 08:10:23 +0200 Subject: [PATCH 002/470] [hoermann_hcp] Add garage light control (#18190) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/hoermann_hcp/hoermann_hcp.cpp | 148 +++- .../components/hoermann_hcp/hoermann_hcp.h | 39 +- .../components/hoermann_hcp/light/__init__.py | 24 + .../hoermann_hcp/light/hoermann_hcp_light.cpp | 82 ++ .../hoermann_hcp/light/hoermann_hcp_light.h | 30 + .../hoermann_hcp_binary_sensor_test.cpp | 30 +- tests/components/hoermann_hcp/common.h | 68 ++ tests/components/hoermann_hcp/common.yaml | 4 + .../cover/hoermann_hcp_cover_test.cpp | 57 +- .../hoermann_hcp/hoermann_hcp_test.cpp | 134 ++- .../light/hoermann_hcp_light_test.cpp | 761 ++++++++++++++++++ 11 files changed, 1211 insertions(+), 166 deletions(-) create mode 100644 esphome/components/hoermann_hcp/light/__init__.py create mode 100644 esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp create mode 100644 esphome/components/hoermann_hcp/light/hoermann_hcp_light.h create mode 100644 tests/components/hoermann_hcp/common.h create mode 100644 tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 0dc146a061..a780854831 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -13,10 +13,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; // High byte of the state register and the door state it stands for. State 0x00 is decoded separately because // its low byte tells a plain stop from the vent position. @@ -58,17 +65,29 @@ void HoermannHcp::update() { // Status broadcasts alone keep the connection alive, so a command the controller never fetches would // otherwise block every later one for as long as it keeps broadcasting. if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { - ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); - this->next_command_ = nullptr; - this->command_written_at_ = 0; - this->clear_target_(); + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; } // A target waits for a door still travelling the other way to turn around. If it never does, the target has // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. - if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); this->clear_target_(); } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, this->on_state_reg_(registers[2]); if (registers.size() > 1) this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } return {}; } @@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { this->command_written_at_ = millis(); ESP_LOGI(TAG, "Sending '%s' command to door", command->name); registers.push_back(command->pressed_value); - registers.push_back(0x0000); + registers.push_back(command->pressed_value_2); return; } if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { - // Still inside the key-press window, so keep presenting 0x0000. + // Between the two events there is nothing to report, including in the second register. push_zeros(registers, 2); return; } @@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { ESP_LOGD(TAG, "Released '%s' command", command->name); this->command_written_at_ = 0; this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); registers.push_back(command->released_value); - registers.push_back(0x0000); + registers.push_back(command->released_value_2); } void HoermannHcp::on_position_reg_(uint16_t value) { @@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) { ESP_LOGW(TAG, "Unknown door state 0x%02X", state); } +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { if (!this->valid_) { // Queueing now would fire the command whenever the controller comes back, which may be much later. @@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { return false; } // A new command supersedes any half-open target the door was still travelling to. - this->clear_target_(); + if (command.clears_target) + this->clear_target_(); this->next_command_ = &command; this->command_queued_at_ = millis(); return true; @@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} bool HoermannHcp::stop_door() { if (!is_moving(this->door_state_)) { @@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) { if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) return false; this->target_position_ = position; + this->target_queued_at_ = millis(); this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; // A door already travelling that way is on its way; one moving the other way has to turn around first. this->target_started_ = this->door_state_ == this->target_direction_; @@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) { } ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. this->next_command_ = nullptr; this->command_written_at_ = 0; - this->clear_target_(); + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; } void HoermannHcp::set_door_state_(DoorState state) { @@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() { this->target_started_ = false; } +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 142365f16e..41fd7617e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -22,11 +22,15 @@ enum class DoorState : uint8_t { }; // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a -// short delay the released value. The second command register remains zero. +// short delay the released value. Each half also carries a second register, which only the lamp command uses. struct HoermannHcpCommand { const char *name; uint16_t pressed_value; uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { @@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool impulse_door(); bool stop_door(); bool set_position(float position); + bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } float get_current_position() const { return this->current_position_; } bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; void record_response_(); // Returns false when the bus controller has not fetched the previous command yet. bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); // Appends the two key-press registers and advances the pending command's press/release state. void push_command_registers_(modbus::RegisterValues ®isters); void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void update_current_position_(); bool has_target_() const { return this->target_position_ != 0.0f; } void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); CallbackManager state_callback_; @@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { // Pending command / key-press state machine. const HoermannHcpCommand *next_command_{nullptr}; uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; uint32_t command_written_at_{0}; uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; // A command is "pressed" for this long before its end value is sent. uint16_t key_press_delay_ms_{100}; @@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { DoorState target_direction_{DoorState::STOPPED}; // Position as reported by the bus controller, 0..200 across the full travel. uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; bool target_started_{false}; bool valid_{false}; bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 3cf708c19e..6e9b567080 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -2,29 +2,9 @@ #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// Exposes the connection bookkeeping so a drop can be driven without waiting one out. -class TestableHoermannHcp : public HoermannHcp { - public: - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { @@ -42,7 +22,7 @@ TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { sensor.setup(); ASSERT_FALSE(sensor.state); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); EXPECT_TRUE(sensor.state); @@ -59,7 +39,7 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { int publishes = 0; sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); ASSERT_EQ(publishes, 1); @@ -69,4 +49,4 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h new file mode 100644 index 0000000000..a6151697f0 --- /dev/null +++ b/tests/components/hoermann_hcp/common.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp::testing { + +using modbus::RegisterValues; + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +inline RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// A status broadcast carrying the lamp register, which the door reports at index 6. +inline RegisterValues lamp_broadcast(uint16_t lamp_reg) { + return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg}); +} + +// The door only accepts commands once the bus controller has actually talked to it. +inline void connect_controller(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + if (response.size() != 8u) + return {0xFFFF, 0xFFFF}; + return {response[2], response[3]}; +} + +// Presents and then releases the queued command, leaving the slot free. +inline void consume_command(HoermannHcp &door) { + poll_command(door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::light_toggle_released_at_; + using HoermannHcp::light_toggles_in_flight_; + using HoermannHcp::set_valid_; +}; + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 84162e8812..552b1cb0fd 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,3 +11,7 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + +light: + - platform: hoermann_hcp + name: Garage Light diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp index 0ec2ed1ddd..43ca47edb2 100644 --- a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -2,36 +2,9 @@ #include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The door only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -} // namespace +namespace esphome::hoermann_hcp::testing { // Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { @@ -92,10 +65,10 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_open().perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // The same for cover.close, which arrives as a position of 0.0. @@ -103,32 +76,32 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_toggle().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); // The door is opening, so it takes an impulse to stop it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); cover.make_call().set_command_stop().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A position between the end stops starts the door in the right direction; it is stopped there later. @@ -136,10 +109,10 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { HoermannHcp door; // starts out fully closed HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_position(0.5f).perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has @@ -153,7 +126,7 @@ TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); EXPECT_EQ(publishes, 1); EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); } @@ -166,9 +139,9 @@ TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { cover.setup(); EXPECT_TRUE(cover.status_has_warning()); - connect(door); + connect_controller(door); door.update(); EXPECT_FALSE(cover.status_has_warning()); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp index 8463c3f605..1cc5301b4a 100644 --- a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -3,51 +3,9 @@ #include #include -#include "esphome/components/hoermann_hcp/hoermann_hcp.h" +#include "common.h" -namespace esphome::hoermann_hcp { - -using modbus::RegisterValues; - -namespace { - -// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. -constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The device only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. -class TestableHoermannHcp : public HoermannHcp { - public: - TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } - - using HoermannHcp::connection_timeout_ms_; - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { @@ -91,7 +49,7 @@ TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { // A queued control command is injected into the next command poll as a simulated key press. TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { HoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); RegisterValues response; @@ -113,31 +71,31 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { // A command is held for the key-press duration, then released, and only then can the next one be queued. TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. door.close_door(); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // With the command gone, the next one is accepted again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } // Commands issued while the bus controller is absent are dropped instead of firing when it returns. TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { HoermannHcp door; door.open_door(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Losing the controller must drop a command it never fetched, otherwise it blocks every later command // and fires unasked once the bus comes back. TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); ASSERT_TRUE(door.is_valid()); @@ -145,10 +103,10 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { EXPECT_FALSE(door.is_valid()); // The reconnecting poll must not replay the dropped command. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // And the slot is free, so a new command is accepted. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The connection is dropped by update() once the controller stops polling, which is what releases a @@ -157,7 +115,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TestableHoermannHcp door; // Wide enough that a stall cannot expire the connection before the check below runs. door.connection_timeout_ms_ = 10000; - connect(door); + connect_controller(door); door.open_door(); // Still inside the window: the controller counts as present. @@ -170,7 +128,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { door.update(); EXPECT_FALSE(door.is_valid()); // The pending command went with the connection instead of firing on the reconnecting poll. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Status broadcasts alone keep the connection alive, so a command the controller never fetches has to @@ -178,7 +136,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.open_door(); std::this_thread::sleep_for(std::chrono::milliseconds(220)); @@ -189,7 +147,7 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { // With the stale command gone, the door accepts commands again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed @@ -272,7 +230,7 @@ TEST(HoermannHcpWrite, EndStopsReportExactPositions) { // A position request below the lower snap threshold becomes a plain close command. TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { HoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.02f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -283,7 +241,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { // A half-open target starts the door moving towards the requested position. TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { HoermannHcp door; // starts out fully closed - connect(door); + connect_controller(door); door.set_position(0.5f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -294,31 +252,31 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { // The door has no notion of a target, so it is stopped with an impulse once it travels past the request. TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Position 120/200 = 0.6 is past the target, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once // must be read as "already stopped" rather than "still opening". TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); @@ -326,17 +284,17 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); ASSERT_EQ(door.get_door_state(), DoorState::OPEN); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The door is stopped at 0.3 by a wall button, short of the requested 0.5. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); @@ -346,48 +304,48 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target armed while the door is still travelling the other way must not be judged by that old direction, // otherwise the very next position it reports counts as reached and stops the door where it stands. TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { TestableHoermannHcp door; - connect(door); + connect_controller(door); // The door is closing, passing 60/200 = 0.3. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Still closing at 58/200 = 0.29: below the target, but not on the way to it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Now opening at 62/200 = 0.31, still short of the target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Past the target at 110/200 = 0.55, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A motor turning around can report a momentary stop; dropping the target there would let the door run on // to the end stop that the reversing command asked for. TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The stop reported on the way from closing to opening. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); @@ -395,23 +353,23 @@ TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { // The door then opens and still has to be stopped at the requested position. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); + EXPECT_EQ(poll_command(door).first, 0x0240); } // A door that never turns around has to lose the target as well, otherwise it would cut a later move short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); std::this_thread::sleep_for(std::chrono::milliseconds(220)); // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the @@ -424,7 +382,7 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp new file mode 100644 index 0000000000..ed7e81b279 --- /dev/null +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -0,0 +1,761 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible. +class CountingHoermannHcpLight : public HoermannHcpLight { + public: + using HoermannHcpLight::HoermannHcpLight; + + void write_state(light::LightState *state) override { + this->writes++; + HoermannHcpLight::write_state(state); + } + + int writes{0}; +}; + +// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +struct LightFixture { + TestableHoermannHcp door; + CountingHoermannHcpLight output{&door}; + light::LightState state{&output}; + + explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { + this->state.set_restore_mode(restore_mode); + this->output.setup(); + // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the + // boot ordering tests need to be able to place around the bus controller coming up. + this->state.setup(); + } + + // Brings the bus controller up and lets the platform read the lamp once, which is what a device does before + // any user command can arrive. + void bring_up() { + connect_controller(this->door); + this->report_lamp(false); + } + + // Issues a command the way Home Assistant would, then lets the state machine settle. + void command(bool on) { + auto call = this->state.make_call(); + call.set_state(on); + call.perform(); + this->settle(); + } + + // Delivers a status broadcast and runs the hub's notification pass. + void report_broadcast(const RegisterValues ®isters) { + this->door.on_write_registers(BROADCAST_REG, registers); + this->pump(); + } + + void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); } + + // Runs the hub's notification pass and lets the resulting publishes settle. + void pump() { + this->door.update(); + this->settle(); + } + + void settle() { + for (int i = 0; i < 4; i++) + this->state.loop(); + } + + bool entity_on() { return this->state.remote_values.is_on(); } +}; + +} // namespace + +// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit. +TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) { + HoermannHcp door; + EXPECT_FALSE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014)); + EXPECT_TRUE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(door.is_light_on()); +} + +// The lamp command is the only one that drives the second command register, on both halves of the press. +TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) { + TestableHoermannHcp door; + connect_controller(door); + ASSERT_FALSE(door.is_light_on()); + ASSERT_TRUE(door.toggle_light()); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0800); + EXPECT_EQ(released_2, 0x0200); + + // The command is spent, so the next poll carries nothing. + auto [idle, idle_2] = poll_command(door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// Toggling the lamp must not disturb a cover position the door is still travelling to. +TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + + // Past the target: the door still has to be stopped despite the lamp command in between. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched +// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the +// door a little overshoot but never loses the stop. +TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + // The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); + + // The target survived the refusal, so the next position report still stops the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100})); + auto [stop, stop_2] = poll_command(door); + EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(stop_2, 0x0000); +} + +// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive. +TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // The door is closing, so an opening target is armed but not yet under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + door.update(); + + // The target expired on its own schedule, so a later opening move runs freely. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Without a bus controller the command cannot be delivered, and the caller is told. +TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) { + HoermannHcp door; + EXPECT_FALSE(door.toggle_light()); +} + +// Switching the entity on sends one toggle, and the door's own report does not send a second. +TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, clearing the slot + + // The lamp is now on, and the resulting broadcast must not queue another toggle. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which +// would cancel the user's own command. +TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + // A door movement sets changed_, firing the state callback while the toggle is still queued. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp switched on at the door itself has to reach the entity. +TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_FALSE(fixture.entity_on()); + + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refused command must leave the entity showing the lamp, not the request. +TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) { + LightFixture fixture; // never connected, so the hub refuses every command + + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up +// showing the lamp rather than the request that was refused. +TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_TRUE(fixture.entity_on()); + + // A door movement while the refused toggle is still on the wire must not pull the entity back either. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_TRUE(fixture.entity_on()); + + // The toggle lands and the door confirms it; the entity must already agree. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in +// that gap must not publish the state the lamp is about to leave. +TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, so nothing is pending any more + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported + + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.entity_on()); +} + +// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp +// moving, so the entity has to be brought back to what the lamp actually is. +TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + // The controller keeps broadcasting but never fetches the command, so the connection stays up. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + fixture.pump(); + + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); +} + +// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the +// controller is back and still reporting the lamp unchanged. +TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); // the connection times out and the command goes with it + ASSERT_FALSE(fixture.door.is_valid()); + + connect_controller(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not +// decide whether a toggle is needed after it. +TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + fixture.report_lamp(true); + ASSERT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Back on the bus, but nothing has said what the lamp is doing yet. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(false); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy. +TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + EXPECT_TRUE(fixture.output.status_has_warning()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.output.status_has_warning()); +} + +// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and +// withdraw the one still waiting rather than deciding nothing is needed. +TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // the first toggle is released but not reported back + fixture.command(false); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + + // Two toggles cancel out, so asking for on again means withdrawing the second one. + fixture.command(true); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1); + EXPECT_TRUE(fixture.entity_on()); +} + +// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll +// must not be mistaken for it and swallowed. +TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); // the boot replay lands here, while the lamp is still unknown + + // The first status broadcast arrives, but the hub has not polled yet, so no callback has fired. + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp +// that is already on must not be switched off by that replay. +TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller is already up and reporting the lamp lit before the entity's first loop. + connect_controller(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_TRUE(fixture.door.is_light_on()); + + fixture.settle(); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + // Once the platform has read the lamp the entity follows it, still without commanding anything. + fixture.pump(); + EXPECT_TRUE(fixture.entity_on()); +} + +// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before +// the first status broadcast must not be judged against a lamp state that was never read. +TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller polls for commands, which is enough to connect but carries no lamp register. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(true); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet. +// A reversing request in that window is a real request and has to be sent, not swallowed. +TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); + + fixture.command(false); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); + EXPECT_FALSE(fixture.entity_on()); + + // The first toggle lands and is reported, but the entity is already heading for off. + fixture.report_lamp(true); + EXPECT_FALSE(fixture.entity_on()); + + // The second toggle lands too, and the lamp finally agrees with the request. + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity +// against the next lamp change the door reports. +TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Refused because the bus is down, so no toggle is heading for the lamp. + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); + + // The controller returns and reports the lamp switched on at the door itself. + connect_controller(fixture.door); + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone. +TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + // The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own. + ASSERT_TRUE(door.toggle_light()); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.update(); + + // The target survived the lamp toggle being dropped, so the door is still stopped on the way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A door that takes the key press but never actually switches the lamp must not leave the entity showing the +// request for ever; the wait has to end so the entity can settle back on what the door reports. +TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the door takes press and release, then does nothing + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); // the lamp is still off, and keeps saying so + EXPECT_FALSE(fixture.entity_on()); +} + +// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that +// counts as a change the light never hears about it and swallows the first command. +TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) { + LightFixture fixture; + // A command poll connects the controller without saying anything about the lamp. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // Closed, at rest, lamp off: every field matches the defaults the hub started with. + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000})); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards. +// Which command happened to be in the slot must not change that. +TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + ASSERT_TRUE(door.toggle_light()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + ASSERT_FALSE(door.is_valid()); + + // Back on the bus and travelling past where the target was: nothing should stop the door now. + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door +// that never reports the lamp would leave the entity waiting for ever. +TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the first toggle is released but never reported back + fixture.command(false); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + fixture.command(true); // withdraws the second, leaving the first outstanding + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1); + + // The door still says nothing about the lamp, so the wait has to time out on its own. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_FALSE(fixture.entity_on()); +} + +// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would +// re-enter write_state() on every loop, so the platform would never stop asking to be written. +TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed. + fixture.command(false); + const int settled_writes = fixture.output.writes; + fixture.settle(); + EXPECT_EQ(fixture.output.writes, settled_writes); +} + +// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing +// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning +// the next toggle the moment it is queued. +TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + poll_command(door); // the door is shown the key press + + // The door acts on it and reports the lamp straight away, which settles the count. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_EQ(door.light_toggles_in_flight_, 0); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); // the release, with nothing left to wait for + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// adopted back to what is known rather than turned into a command. +TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { + LightFixture fixture{light::LIGHT_ALWAYS_ON}; + connect_controller(fixture.door); + fixture.settle(); + + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press before the toggle is fetched cancels it, so the lamp never moves. +TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); + + // Nothing is left for the controller to fetch, so the lamp stays off as asked. + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even +// been shown has to keep counting. +TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + // The toggle still in the slot will invert what the door just reported. + EXPECT_FALSE(door.is_light_heading_on()); +} + +// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding, +// so the wait has to end rather than time out against toggles the door was never shown. +TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, so the wait for a lamp report starts + ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown + ASSERT_NE(door.light_toggle_released_at_, 0u); + + // The door reports the lamp change the first toggle caused, leaving only the unsent one. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + ASSERT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is +// going to fire, so it keeps counting. +TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) { + TestableHoermannHcp door; + // Wide enough that the toggle queued after the sleep cannot expire before update() runs. + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, which then says nothing about the lamp + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // Queued just now, so only the wait for the first toggle is overdue. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + door.update(); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_TRUE(door.is_light_toggle_pending_()); + EXPECT_TRUE(door.is_light_heading_on()); +} + +// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away. +TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + + // The door takes every key press but never reports the lamp, so nothing is ever confirmed. + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + } + + EXPECT_FALSE(door.toggle_light()); + EXPECT_EQ(door.light_toggles_in_flight_, 4); +} + +// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag +// itself rather than command against what was read before. +TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000})); + + EXPECT_FALSE(fixture.door.is_light_known()); + EXPECT_TRUE(fixture.output.status_has_warning()); +} + +// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that +// gap, the write still carries the old value and must not be taken for a request to invert the lamp. +TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) { + LightFixture fixture; + fixture.bring_up(); + // A door command holds the only command slot, so the request below is refused and the lamp published back. + ASSERT_TRUE(fixture.door.open_door()); + + auto call = fixture.state.make_call(); + call.set_state(true); + call.perform(); + fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass + + // The slot frees up and the lamp is switched on at the door before that publish arrives. + consume_command(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + fixture.settle(); + + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_TRUE(fixture.entity_on()); +} + +} // namespace esphome::hoermann_hcp::testing From 58a42fe5c27bdb2158d8c444d544f473fd0e3b0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 01:44:35 -0500 Subject: [PATCH 003/470] [core] Batch remote file downloads during config validation (#18069) --- esphome/components/animation/__init__.py | 5 + esphome/components/animation/image.py | 5 + esphome/components/bme68x_bsec2/__init__.py | 64 +++- .../components/bme68x_bsec2_i2c/__init__.py | 7 +- esphome/components/esp32/__init__.py | 50 ++- esphome/components/file/image.py | 81 ++-- esphome/components/font/__init__.py | 246 +++++++++--- esphome/components/gsl3670/touchscreen.py | 22 +- .../components/micro_wake_word/__init__.py | 16 +- esphome/components/shelly_dimmer/light.py | 108 ++++-- esphome/config.py | 128 ++++++- esphome/external_files.py | 224 +++++++++-- esphome/loader.py | 18 +- tests/component_tests/gsl3670/test_init.py | 22 +- .../components/bme68x_bsec2/__init__.py | 0 .../components/bme68x_bsec2/test_init.py | 64 ++++ tests/unit_tests/components/file/__init__.py | 0 .../unit_tests/components/file/test_image.py | 75 ++++ tests/unit_tests/components/font/__init__.py | 0 tests/unit_tests/components/font/test_init.py | 229 +++++++++++ .../unit_tests/components/gsl3670/__init__.py | 0 .../components/gsl3670/test_touchscreen.py | 35 ++ .../components/micro_wake_word/test_init.py | 9 +- .../components/shelly_dimmer/__init__.py | 0 .../components/shelly_dimmer/test_light.py | 154 ++++++++ tests/unit_tests/test_config_prefetch.py | 355 ++++++++++++++++++ tests/unit_tests/test_external_files.py | 341 +++++++++++++++-- 27 files changed, 2005 insertions(+), 253 deletions(-) create mode 100644 tests/unit_tests/components/bme68x_bsec2/__init__.py create mode 100644 tests/unit_tests/components/bme68x_bsec2/test_init.py create mode 100644 tests/unit_tests/components/file/__init__.py create mode 100644 tests/unit_tests/components/file/test_image.py create mode 100644 tests/unit_tests/components/font/__init__.py create mode 100644 tests/unit_tests/components/font/test_init.py create mode 100644 tests/unit_tests/components/gsl3670/__init__.py create mode 100644 tests/unit_tests/components/gsl3670/test_touchscreen.py create mode 100644 tests/unit_tests/components/shelly_dimmer/__init__.py create mode 100644 tests/unit_tests/components/shelly_dimmer/test_light.py create mode 100644 tests/unit_tests/test_config_prefetch.py diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 0df7c56313..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b0..73d428bd20 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv @@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES AUTO_LOAD = ["file"] DEPENDENCIES = ["display"] diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 63f63c5da2..c12eb39d2d 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,6 +11,8 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -105,6 +102,42 @@ def download_bme68x_blob(config): return config +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + def validate_bme68x(config): if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..dacd4e32ad 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, @@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2e72c78974..ada6d25db5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3280,27 +3280,45 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - from esphome.happy_eyeballs import ensure_happy_eyeballs - - ensure_happy_eyeballs() - - try: - req = requests.get(path, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise EsphomeError( - f"Could not download extra build file {path}: {e}" - ) from e - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - CORE.relative_build_path(name).write_bytes(req.content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79..b54c3f2adf 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path @@ -43,15 +42,13 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, MockObjClass +from esphome.external_files import RemoteFile from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,16 +62,16 @@ MDI_SOURCES = { SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", } +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") -def compute_local_image_path(value) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def local_path(value): @@ -83,16 +80,20 @@ def local_path(value): def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) return str(path) -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + mdi_id + ".svg" + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) @@ -101,17 +102,53 @@ def download_image(value): return download_file(value, compute_local_image_path(value)) -def validate_file_shorthand(value): - value = cv.string_strict(value) +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) value = cv.file_(value) return local_path(value) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 5872b607f1..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,7 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -296,46 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" ) from e - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -346,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -358,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith(("http://", "https://")): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index fc0318f076..ccccf06d69 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All( ) +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def _config_schema(config): model_option = { cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 255923f878..092c4977ce 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema( def _compute_local_file_path(config: dict) -> Path: - url = config[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index cd6d858067..dd99fcbc90 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -2,9 +2,7 @@ import hashlib from pathlib import Path import re -import requests - -from esphome import pins +from esphome import external_files, pins import esphome.codegen as cg from esphome.components import light, sensor, uart from esphome.components.const import CONF_SHA256 @@ -28,8 +26,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.core import HexInt +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -76,46 +75,85 @@ def parse_firmware_version(value): return major, minor -def get_firmware(value): +def _firmware_cache_path(name: str) -> Path: + return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin" + + +def _firmware_path(url: str, sha: str | None) -> Path: + """Cache path for a firmware blob: sha-keyed when verifiable, else + URL-keyed. Shared by the validator and the prefetch hook.""" + return _firmware_cache_path( + sha.lower() if sha else external_files.url_cache_key(url) + ) + + +def get_firmware(value: ConfigType) -> list[HexInt] | None: if not value[CONF_UPDATE]: return None - def dl(url): - try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e - - h = hashlib.new("sha256") - h.update(req.content) - return req.content, h.hexdigest() - url = value[CONF_URL] - if CONF_SHA256 in value: # we have a hash, enable caching - path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin") - - if not path.is_file(): - firmware_data, dl_hash = dl(url) - - if dl_hash != value[CONF_SHA256]: - raise cv.Invalid( - f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}" - ) - - path.parent.mkdir(exist_ok=True, parents=True) - path.write_bytes(firmware_data) - - else: + if expected := value.get(CONF_SHA256): + expected = expected.lower() + path = _firmware_path(url, expected) + if path.is_file(): firmware_data = path.read_bytes() - else: # no caching, download every time - firmware_data, dl_hash = dl(url) + if hashlib.sha256(firmware_data).hexdigest() == expected: + return [HexInt(x) for x in firmware_data] + # A corrupted or foreign cache entry must never be trusted just + # because the file exists; discard it and download again. + path.unlink() + firmware_data = external_files.download_content(url, path) + if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected: + path.unlink(missing_ok=True) + raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}") + else: + # No hash to verify the bytes, so an unrevalidated copy is an + # error rather than a silent fallback. + firmware_data = external_files.download_content( + url, + _firmware_path(url, None), + allow_stale=False, + ) return [HexInt(x) for x in firmware_data] +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if not isinstance(firmware, dict): + return None + try: + # cv.boolean, not truthiness: `update: "false"` is a valid False. + if not cv.boolean(firmware.get(CONF_UPDATE, False)): + return None + except cv.Invalid: + return None + url = firmware.get(CONF_URL) + sha = firmware.get(CONF_SHA256) + if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))): + url, sha = known + if not isinstance(url, str): + return None + if sha is not None: + # Reject anything but a well-formed hash; a raw string would + # otherwise become a path component before validation runs. + try: + sha = validate_sha256(sha) + except (cv.Invalid, ValueError, TypeError): + return None + path = _firmware_path(url, sha) + if sha is not None and path.is_file(): + # Content-addressed and already on disk; get_firmware verifies it + # by hash, so there is nothing to revalidate. + return None + # No hash means no stale copies, matching the validator's policy. + return RemoteFile(url, path, allow_stale=sha is not None) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def validate_firmware(value): config = value.copy() if CONF_URL not in config: diff --git a/esphome/config.py b/esphome/config.py index b747c69b3a..987bb9c96a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,14 +1,15 @@ from __future__ import annotations import abc -from contextlib import contextmanager +from collections.abc import Iterator +from contextlib import contextmanager, suppress import contextvars import copy import functools import heapq import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret +if TYPE_CHECKING: + from esphome.external_files import RemoteFile + _LOGGER = logging.getLogger(__name__) @@ -717,6 +721,125 @@ class AutoLoadValidationStep(ConfigValidationStep): ) +# Backstop against a runaway PREFETCH_FILES generator; no real component +# needs anywhere near this many stages (font, the deepest, uses two). +_MAX_PREFETCH_STAGES = 10 + + +class PrefetchRemoteFilesValidationStep(ConfigValidationStep): + """Batch-download remote files referenced by the raw config. + + Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see + ``ComponentManifest.prefetch_files``) download in one parallel pass, so + per-entry schema validators find a warm cache. Must run between + AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0): + metadata steps push priority-0 schema steps that pop immediately, so + this is the last point where every raw entry list is intact. Best + effort: failures are logged and memoized per run; the per-entry + validators stay authoritative. + """ + + priority = -1.5 + + def run(self, result: Config) -> None: + active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + + def warn_hook_failed(name: str, err: Exception) -> None: + # A broken hook must not fail validation; it only loses the + # batching speedup. + _LOGGER.warning("Remote file prefetch for %s failed: %s", name, err) + _LOGGER.debug("Prefetch hook traceback", exc_info=err) + + def start_hook( + name: str, manifest: ComponentManifest, entries: list[ConfigType] + ) -> None: + if (hook := manifest.prefetch_files) is None: + return + try: + active.append((name, iter(hook(entries)))) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + + for domain, conf in result.items(): + if not isinstance(domain, str) or domain.startswith("."): + continue + if (component := get_component(domain)) is None: + continue + if component.prefetch_files is None and not component.is_platform_component: + continue + if conf is None or isinstance(conf, core.AutoLoad): + continue + entries = [ + entry + for entry in (conf if isinstance(conf, list) else [conf]) + if isinstance(entry, dict) + ] + if not entries: + continue + # A domain-level hook on a platform component receives every + # entry; overlap with per-platform hooks dedupes by path. + start_hook(domain, component, entries) + if not component.is_platform_component: + continue + by_platform: dict[str, list[ConfigType]] = {} + for entry in entries: + if isinstance(p_name := entry.get(CONF_PLATFORM), str): + by_platform.setdefault(p_name, []).append(entry) + for p_name, p_entries in by_platform.items(): + if (platform := get_platform(domain, p_name)) is not None: + start_hook(f"{domain}.{p_name}", platform, p_entries) + + # One stage per round; later stages can read what earlier ones + # fetched. + for _ in range(_MAX_PREFETCH_STAGES): + if not active: + break + items: list[RemoteFile] = [] + still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + for name, generator in active: + try: + batch = list(next(generator)) + except StopIteration: + continue + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + continue + items.extend(batch) + still_active.append((name, generator)) + active = still_active + self._download(items) + for name, generator in active: + # A tripped backstop means a broken hook. + _LOGGER.warning( + "Remote file prefetch for %s stopped after %d stages", + name, + _MAX_PREFETCH_STAGES, + ) + if (close := getattr(generator, "close", None)) is not None: + # close() runs hook code too; it must not fail validation. + with suppress(Exception): + close() + + @staticmethod + def _download(items: list[RemoteFile]) -> None: + if not items: + return + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when a config actually references remote files. + from esphome import external_files + + try: + external_files.download_content_many(items, description="remote file(s)") + except cv.Invalid as err: + # INFO: the trace if an extractor's cache path ever drifts from + # its validator's, hiding the memoized failure replay. + _LOGGER.info("Remote file prefetch download failed: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # The batch downloader itself broke; make it visible. + _LOGGER.warning("Remote file prefetch failed: %s", err) + _LOGGER.debug("Prefetch download traceback", exc_info=err) + + class MetadataValidationStep(ConfigValidationStep): """Validate component metadata @@ -1259,6 +1382,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) + result.add_validation_step(PrefetchRemoteFilesValidationStep()) result.add_validation_step(IDPassValidationStep()) result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) diff --git a/esphome/external_files.py b/esphome/external_files.py index 160a2b6c29..f30d429425 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,16 +1,16 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from concurrent.futures import ThreadPoolExecutor import contextlib +from dataclasses import dataclass, field from datetime import UTC, datetime +import hashlib import logging import os from pathlib import Path import time -import requests - import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds @@ -21,8 +21,54 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] +DOMAIN = "external_files" + NETWORK_TIMEOUT = 30 + +@dataclass(frozen=True, slots=True) +class RemoteFile: + """A remote file to prefetch, yielded in stages by ``PREFETCH_FILES`` + hooks. A dataclass rather than a tuple so fields can be added later.""" + + url: str + path: Path + # False when nothing downstream can verify the bytes; a copy that + # cannot be revalidated is then an error, not a silent fallback. + allow_stale: bool = True + + +@dataclass(frozen=True, slots=True) +class FailedDownload: + """What went wrong for a cache path this run, kept for fast replay.""" + + url: str + message: str + cause: BaseException + + +@dataclass +class ExternalFilesRunData: + """Per-run download state, cleared by ``CORE.reset()`` between runs.""" + + # Verified fresh this run; later touches skip even the conditional HEAD. + fresh_paths: set[Path] = field(default_factory=set) + # Served from disk without revalidation; strict callers reject these. + stale_paths: set[Path] = field(default_factory=set) + # Served under skip_external_update, deliberately unchecked; skips the + # network like fresh_paths but never counts as verified. + unchecked_paths: set[Path] = field(default_factory=set) + # Failed with no usable copy; later touches replay the error fast. + failed_paths: dict[Path, FailedDownload] = field(default_factory=dict) + + +def _run_data() -> ExternalFilesRunData: + if (data := CORE.data.get(DOMAIN)) is not None: + return data + # setdefault: first touch may race on download_content_many's workers. + return CORE.data.setdefault(DOMAIN, ExternalFilesRunData()) + + IF_MODIFIED_SINCE = "If-Modified-Since" IF_NONE_MATCH = "If-None-Match" ETAG = "ETag" @@ -93,6 +139,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + # Deferred so configs with no remote files skip the heavy import. + import requests + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -127,6 +176,9 @@ def has_remote_file_changed( ) if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) + # A confirmed 304 supersedes any earlier failed + # revalidation of this file. + _run_data().stale_paths.discard(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -136,6 +188,9 @@ def has_remote_file_changed( url, e, ) + # The copy is a fallback, not a verified 304; record that so + # callers that must not use unverified bytes can reject it. + _run_data().stale_paths.add(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) @@ -159,14 +214,81 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: +def url_cache_key(url: str) -> str: + """Short stable cache key for a URL.""" + return hashlib.sha256(url.encode()).hexdigest()[:8] + + +def compute_local_file_path(domain: str, url: str) -> Path: + """Cache path for a URL-keyed download under the domain's cache dir. + + Pure (no mkdir); parent directories are created at write time. + """ + return Path(CORE.data_dir) / domain / url_cache_key(url) + + +def is_fresh_this_run(path: Path) -> bool: + """Whether `path` was verified or downloaded during this run.""" + return path in _run_data().fresh_paths + + +def download_content( + url: str, + path: Path, + timeout: int = NETWORK_TIMEOUT, + allow_stale: bool = True, + return_content: bool = True, +) -> bytes: + """Download `url` into `path` and return the bytes, using the cache. + + On network failure an on-disk copy is served with a warning, unless + ``allow_stale=False``. ``CORE.skip_external_update`` always serves the + copy. ``return_content=False`` skips the disk read on cache hits. + """ + + # Deferred so configs with no remote files skip the heavy import. + import requests + + def _cached() -> bytes: + return path.read_bytes() if return_content else b"" + + # Memoized paths skip the network entirely; concurrent access is safe + # because download_content_many dedupes by path before fanning out. + run_data = _run_data() + fresh_paths = run_data.fresh_paths + if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists(): + return _cached() + if allow_stale and path in run_data.stale_paths and path.exists(): + # Strict callers fall through to try the network themselves. + _LOGGER.info("Using cached copy of %s that could not be revalidated", url) + return _cached() + if (failure := run_data.failed_paths.get(path)) is not None: + if not path.exists(): + if failure.url == url: + raise cv.Invalid(failure.message) from failure.cause + raise cv.Invalid( + f"Could not download from {url}: an earlier download of " + f"{failure.url} to the same cache file failed: {failure.cause}" + ) from failure.cause + # The file appeared since the failure; revalidate normally. + del run_data.failed_paths[path] ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) - return path.read_bytes() + run_data.unchecked_paths.add(path) + return _cached() if not has_remote_file_changed(url, path, timeout): + if path in run_data.stale_paths: + # The HEAD fell back to the copy without confirming it. + if not allow_stale: + raise cv.Invalid( + f"Could not check {url} for updates due to a network error " + f"and the cached copy cannot be verified" + ) + return _cached() _LOGGER.debug("Remote file has not changed %s", url) - return path.read_bytes() + fresh_paths.add(path) + return _cached() _LOGGER.info("Downloading %s", url) _LOGGER.debug("Saving to %s", path) @@ -185,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content except requests.exceptions.RequestException as e: if path.exists(): + # Memoized so a flaky host warns once per run, not per consumer. + run_data.stale_paths.add(path) + if not allow_stale: + raise cv.Invalid(f"Could not download from {url}: {e}") from e _LOGGER.warning( "Could not download from %s due to network error (%s), using cached file", url, e, ) - return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") from e + return _cached() + message = f"Could not download from {url}: {e}" + run_data.failed_paths[path] = FailedDownload(url, message, e) + raise cv.Invalid(message) from e write_file(path, data) _write_etag(path, req.headers.get(ETAG)) + fresh_paths.add(path) + run_data.stale_paths.discard(path) return data @@ -207,50 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( - items: Iterable[tuple[str, Path]], + items: Iterable[RemoteFile], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, description: str = "remote file(s)", ) -> None: - """Run `download_content` for each (url, path) pair concurrently. + """Run `download_content` for each `RemoteFile` concurrently. - `description` names the kind of files in the progress log line, e.g. - "wake word manifest(s)". - - Wall time drops from `sum(latency)` to roughly `max(latency)` for cached - files where the HEAD round-trip dominates. All workers run to - completion before this returns; every `cv.Invalid` raised by a worker - is collected and surfaced together as `cv.MultipleInvalid` so the user - sees every broken file in a single validation pass instead of fixing - them one round-trip at a time. - - Items are de-duplicated by `path` -- two callers asking for the same - cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. When the - same `path` appears more than once, the last URL wins (standard dict - comprehension semantics); in practice duplicate paths only arise when - the URL is duplicated, so the choice doesn't matter. + `description` names the files in the progress log line. All workers run + to completion; every `cv.Invalid` raised is surfaced together as + `cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on + the same cache file); the last URL wins and a strict + `allow_stale=False` from any duplicate is kept. """ - seen: dict[Path, str] = {path: url for url, path in items} - if not seen: + seen: dict[Path, RemoteFile] = {} + for file in items: + if (prior := seen.get(file.path)) is not None and not prior.allow_stale: + file = RemoteFile(file.url, file.path, allow_stale=False) + seen[file.path] = file + unique = list(seen.values()) + if not unique: return ensure_happy_eyeballs() - _LOGGER.info("Checking %d %s for updates", len(seen), description) - if len(seen) == 1: - path, url = next(iter(seen.items())) - download_content(url, path, timeout) + _LOGGER.info("Checking %d %s for updates", len(unique), description) + + def _download_one(file: RemoteFile) -> None: + download_content( + file.url, + file.path, + timeout, + allow_stale=file.allow_stale, + return_content=False, + ) + + if len(unique) == 1: + _download_one(unique[0]) return - def _download_one(path_url: tuple[Path, str]) -> None: - # `seen` stores entries as (path, url) so the dict can dedupe by - # path; flip them back to download_content's (url, path) order. - path, url = path_url - download_content(url, path, timeout) - - workers = max(1, min(max_workers, len(seen))) + workers = max(1, min(max_workers, len(unique))) errors: list[cv.Invalid] = [] with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(_download_one, item) for item in seen.items()] + futures = [ex.submit(_download_one, file) for file in unique] for future in futures: try: future.result() @@ -263,6 +390,21 @@ def download_content_many( raise cv.MultipleInvalid(errors) +def single_stage_prefetch( + extract: Callable[[ConfigType], RemoteFile | None], +) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]: + """Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor. + + Covers the common case of one remote file per raw config entry; + components with staged downloads write their own generator. + """ + + def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]: + yield [ref for entry in entries if (ref := extract(entry)) is not None] + + return prefetch_files + + # Each component that uses external_files defines its own local # `TYPE_WEB = "web"`; the string is repeated here rather than imported # because there is no canonical `TYPE_WEB` in `esphome.const` to share. @@ -282,7 +424,7 @@ def download_web_files_in_config( slotted directly into a `cv.All(...)` chain. """ download_content_many( - (conf_file[CONF_URL], path_for(conf_file)) + RemoteFile(conf_file[CONF_URL], path_for(conf_file)) for entry in config if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE ) diff --git a/esphome/loader.py b/esphome/loader.py index 22db8b156a..7a659aa0a8 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -16,6 +16,7 @@ from esphome.types import ConfigType if TYPE_CHECKING: from esphome.cpp_generator import MockObjClass + from esphome.external_files import RemoteFile # `esphome.core.config` is imported lazily in `_lookup_module` when the # "esphome" pseudo-component is first resolved. It pulls in @@ -135,6 +136,21 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def prefetch_files( + self, + ) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None: + """Optional `PREFETCH_FILES` hook for batched remote file downloads. + + A generator called once per run with the component's raw, pre-schema + config entries; each yield is a stage of ``RemoteFile`` downloaded in + one parallel pass before schema validation, so a later stage may + derive URLs from earlier files' content. Best effort: skip anything + unrecognized. On platform components, place it on the platform + sub-module; a domain-module hook receives every entry. + """ + return getattr(self.module, "PREFETCH_FILES", None) + @property def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 8528cf23ca..950fa389be 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The cache path is derived from (and stable for) the URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) first = gsl._cache_path(VALID_URL) assert first == gsl._cache_path(VALID_URL) assert first != gsl._cache_path("https://example.com/other.bin") - assert first.parent == tmp_path + assert first.parent == tmp_path / "gsl3670" def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: @@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """A ``url`` source resolves to the cache path for that URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) @@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates( ) -> None: """A url source downloads the content and validates its structure.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} @@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected( ) -> None: """A configured SHA-256 that does not match the download is rejected.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) @@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Downloaded content that is not a valid blob is rejected.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr( gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" ) diff --git a/tests/unit_tests/components/bme68x_bsec2/__init__.py b/tests/unit_tests/components/bme68x_bsec2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/bme68x_bsec2/test_init.py b/tests/unit_tests/components/bme68x_bsec2/test_init.py new file mode 100644 index 0000000000..b34231a1aa --- /dev/null +++ b/tests/unit_tests/components/bme68x_bsec2/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the bme68x_bsec2 prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components import bme68x_bsec2 as bsec +from esphome.loader import get_component + + +def test_prefetch_applies_defaults(setup_core: Path) -> None: + [files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}])) + assert len(files) == 1 + assert "bme680_iaq_33v_3s_28d" in files[0].url + assert files[0].path == bsec._compute_local_file_path(files[0].url) + + +def test_prefetch_normalizes_enum_case(setup_core: Path) -> None: + [files] = list( + bsec.PREFETCH_FILES( + [ + { + "model": "BME688", + "sample_rate": "ulp", + "supply_voltage": "1.8v", + "algorithm_output": "REGRESSION", + "operating_age": "4D", + } + ] + ) + ) + assert len(files) == 1 + assert "bme688_reg_18v_300s_4d" in files[0].url + + +def test_prefetch_skips_unknown_values(setup_core: Path) -> None: + entries = [ + {"model": "bme999"}, + {"model": "bme680", "sample_rate": "TURBO"}, + {"model": "bme680", "algorithm_output": "psychic"}, + {}, + ] + assert list(bsec.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_matches_validator_url(setup_core: Path) -> None: + """The hook's URL equals _compute_url over the validated config shape.""" + validated = { + "model": "bme688", + "operating_age": "28d", + "sample_rate": "LP", + "supply_voltage": "3.3V", + "algorithm_output": "classification", + } + [files] = list(bsec.PREFETCH_FILES([dict(validated)])) + assert files[0].url == bsec._compute_url(validated) + + +def test_hook_is_wired_to_the_user_facing_domain() -> None: + """The i2c domain (the only user-facing one) exposes the hook.""" + + component = get_component("bme68x_bsec2_i2c") + assert component is not None + assert component.prefetch_files is bsec.PREFETCH_FILES diff --git a/tests/unit_tests/components/file/__init__.py b/tests/unit_tests/components/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py new file mode 100644 index 0000000000..a9c1684db3 --- /dev/null +++ b/tests/unit_tests/components/file/test_image.py @@ -0,0 +1,75 @@ +"""Tests for the file image platform's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from esphome.components.file import image as file_image +from esphome.external_files import RemoteFile +from esphome.loader import get_component, get_platform + + +def test_extract_mdi_shorthand(setup_core: Path) -> None: + ref = file_image._extract_file_ref("mdi:home") + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg" + assert ref.path.name == "home.svg" + assert ref.path.parent.name == "mdi" + + +def test_extract_web_url(setup_core: Path) -> None: + url = "https://example.com/img.png" + ref = file_image._extract_file_ref(url) + assert ref == RemoteFile(url, file_image.compute_local_image_path(url)) + + +def test_extract_typed_dicts(setup_core: Path) -> None: + url = "https://example.com/img.png" + assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile( + url, file_image.compute_local_image_path(url) + ) + ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"}) + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg" + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert file_image._extract_file_ref("images/local.png") is None + assert file_image._extract_file_ref("mdi:not a valid icon!") is None + assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None + assert file_image._extract_file_ref(42) is None + assert file_image._extract_file_ref(None) is None + + +def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: + entries = [ + {"file": "mdi:home"}, + {"file": "images/local.png"}, + {"file": "https://example.com/img.png"}, + {"no_file_key": True}, + ] + [files] = list(file_image.PREFETCH_FILES(entries)) + assert len(files) == 2 + assert files[0].url.endswith("home.svg") + assert files[1].url == "https://example.com/img.png" + + +def test_extractor_matches_validator_path(setup_core: Path) -> None: + """The path the validator downloads to equals the extractor's path.""" + with patch( + "esphome.components.file.image.external_files.download_content" + ) as mock_download: + file_image.validate_file_shorthand("mdi:home") + + validated_path = mock_download.call_args[0][1] + assert validated_path == file_image._extract_file_ref("mdi:home").path + + +def test_hook_is_wired_to_both_animation_domains() -> None: + """Both animation entry points expose the shared image hook.""" + + assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES + assert ( + get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES + ) diff --git a/tests/unit_tests/components/font/__init__.py b/tests/unit_tests/components/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/font/test_init.py b/tests/unit_tests/components/font/test_init.py new file mode 100644 index 0000000000..0ea3a0e3a1 --- /dev/null +++ b/tests/unit_tests/components/font/test_init.py @@ -0,0 +1,229 @@ +"""Tests for the font component's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import external_files +from esphome.components import font +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict: + return {"family": family, "weight": weight, "italic": italic} + + +def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None: + spec = font._extract_remote_font("gfonts://Roboto") + assert spec is not None + assert spec[font.CONF_FAMILY] == "Roboto" + assert spec[font.CONF_WEIGHT] == 400 + assert spec[font.CONF_ITALIC] is False + + +def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None: + assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700 + assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500 + + +def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None: + """Boolean spellings the schema accepts are accepted by the extractor.""" + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "true"} + ) + assert spec is not None + assert spec[font.CONF_ITALIC] is True + assert ( + font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "maybe"} + ) + is None + ) + + +def test_extract_typed_gfonts_dict(setup_core: Path) -> None: + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True} + ) + assert spec is not None + assert spec[font.CONF_WEIGHT] == 500 + assert spec[font.CONF_ITALIC] is True + + +def test_extract_web_font(setup_core: Path) -> None: + url = "https://example.com/font.ttf" + for value in (url, {"type": "web", "url": url}): + spec = font._extract_remote_font(value) + assert spec is not None + assert spec[font.CONF_URL] == url + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert font._extract_remote_font("fonts/local.ttf") is None + assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None + assert ( + font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"}) + is None + ) + assert font._extract_remote_font(42) is None + + +def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None: + entries = [ + {"file": "gfonts://Roboto"}, + {"file": "fonts/local.ttf"}, + { + "file": "https://example.com/font.ttf", + "extras": [{"file": "gfonts://Monocraft"}], + }, + ] + batches = list(font.PREFETCH_FILES(entries)) + urls = [file.url for file in batches[0]] + assert font._gfonts_css_url(_gspec("Roboto")) in urls + assert font._gfonts_css_url(_gspec("Monocraft")) in urls + assert "https://example.com/font.ttf" in urls + assert len(batches[0]) == 3 + + +def test_prefetch_skips_recent_ttf(setup_core: Path) -> None: + path = font._gfonts_ttf_path(_gspec("Roboto")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached ttf") + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches == [[], []] + + +def test_stage2_parses_cached_css(setup_core: Path) -> None: + + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');" + ) + # Stage two only trusts CSS confirmed fetched this run. + external_files._run_data().fresh_paths.add(css_path) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [ + RemoteFile( + "https://fonts.gstatic.com/roboto.ttf", + font._gfonts_ttf_path(_gspec("Roboto")), + ) + ] + + +def test_stage2_skips_missing_css(setup_core: Path) -> None: + batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}])) + assert batches[1] == [] + + +def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None: + """A bare-mapping extras value (valid raw config) is scanned.""" + entries = [ + { + "file": "fonts/local.ttf", + "extras": {"file": "gfonts://Roboto", "glyphs": "ABC"}, + } + ] + batches = list(font.PREFETCH_FILES(entries)) + assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))] + + +def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None: + """A CSS body that fails to parse is removed from the cache.""" + + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + css_path = font._gfonts_css_path(spec) + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"no truetype url here", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="please report this"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"\xff\xfe\x00\x01binary", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="not a text document"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + +def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None: + """A CSS body that could not be revalidated is not parsed for a ttf + URL; the cached font is used instead.""" + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + ttf_path = font._gfonts_ttf_path(spec) + ttf_path.parent.mkdir(parents=True, exist_ok=True) + ttf_path.write_bytes(b"cached ttf") + cache = MagicMock() + with ( + patch.object(font, "FONT_CACHE", cache), + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + ): + assert font.download_gfont(spec) is spec + cache.__setitem__.assert_called_once_with(spec, ttf_path) + + +def test_unrevalidated_gfonts_css_without_cached_font_errors( + setup_core: Path, +) -> None: + """No verified CSS and no cached font is a clear error.""" + spec = { + "family": "Roboto", + "weight": 500, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + pytest.raises(cv.Invalid, match="no cached font"), + ): + font.download_gfont(spec) + + +def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None: + """A leftover CSS from an earlier run is not trusted for stage two.""" + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');" + ) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [] diff --git a/tests/unit_tests/components/gsl3670/__init__.py b/tests/unit_tests/components/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/gsl3670/test_touchscreen.py b/tests/unit_tests/components/gsl3670/test_touchscreen.py new file mode 100644 index 0000000000..a4b96d72da --- /dev/null +++ b/tests/unit_tests/components/gsl3670/test_touchscreen.py @@ -0,0 +1,35 @@ +"""Tests for the gsl3670 touchscreen prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.external_files import RemoteFile + + +def test_prefetch_explicit_url(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"platform": "gsl3670", "firmware": {"url": url}}] + assert list(gsl.PREFETCH_FILES(entries)) == [ + [RemoteFile(url, gsl._cache_path(url))] + ] + + +def test_prefetch_model_default_firmware(setup_core: Path) -> None: + entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}] + [files] = list(gsl.PREFETCH_FILES(entries)) + assert len(files) == 1 + assert ( + files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"] + ) + assert files[0].path == gsl._cache_path(files[0].url) + + +def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None: + entries = [ + {"platform": "gsl3670", "firmware": {"file": "fw.bin"}}, + {"platform": "gsl3670", "model": "CUSTOM"}, + {"platform": "gsl3670"}, + ] + assert list(gsl.PREFETCH_FILES(entries)) == [[]] diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py index 84371ab906..96fb73b18b 100644 --- a/tests/unit_tests/components/micro_wake_word/test_init.py +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) +from esphome.external_files import RemoteFile @pytest.fixture @@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models( assert mock_download_content_many.call_count == 2 manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) assert manifest_items == [ - (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + RemoteFile( + f"https://example.com/models/{name}.json", paths[name] / "manifest.json" + ) for name in names ] model_items = list(mock_download_content_many.call_args_list[1].args[0]) assert model_items == [ - (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + RemoteFile( + f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite" + ) for name in names ] diff --git a/tests/unit_tests/components/shelly_dimmer/__init__.py b/tests/unit_tests/components/shelly_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/shelly_dimmer/test_light.py b/tests/unit_tests/components/shelly_dimmer/test_light.py new file mode 100644 index 0000000000..e5440db4c9 --- /dev/null +++ b/tests/unit_tests/components/shelly_dimmer/test_light.py @@ -0,0 +1,154 @@ +"""Tests for the shelly_dimmer firmware download and prefetch extraction.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome import external_files +from esphome.components.shelly_dimmer import light as shd +from esphome.config_validation import Invalid +from esphome.external_files import RemoteFile + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_prefetch_known_version(setup_core: Path) -> None: + entries = [{"firmware": {"version": "51.6", "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + url, sha = shd.KNOWN_FIRMWARE["51.6"] + assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]] + + +def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None: + """Quoted booleans behave as the schema will normalize them.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + off = [{"firmware": {"version": "51.6", "update": "false"}}] + assert list(shd.PREFETCH_FILES(off)) == [[]] + on = [{"firmware": {"version": "51.6", "update": "true"}}] + assert list(shd.PREFETCH_FILES(on)) == [ + [RemoteFile(url, shd._firmware_cache_path(sha))] + ] + + +def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None: + """A raw sha256 that is not a hash never becomes a path component.""" + entries = [ + { + "firmware": { + "url": "https://example.com/fw.bin", + "sha256": "/tmp/payload", + "update": True, + } + } + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None: + """A sha-keyed cache file needs no revalidation; get_firmware hashes it.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + shd._firmware_cache_path(sha).write_bytes(b"pinned firmware") + entries = [{"firmware": {"version": "51.6", "update": True}}] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"firmware": {"url": url, "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + key = external_files.url_cache_key(url) + # No sha means the bytes cannot be verified, so the prefetch itself + # must carry the validator's strict no-stale policy. + assert stages == [ + [RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)] + ] + + +def test_prefetch_skips_no_update(setup_core: Path) -> None: + entries = [ + {"firmware": {"version": "51.6"}}, + {"firmware": "51.6"}, + {"firmware": {"version": "0.0", "update": True}}, + {}, + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None: + """A cached blob failing its hash check is discarded and re-downloaded.""" + good = b"good firmware" + expected = _sha(good) + path = shd._firmware_cache_path(expected) + path.write_bytes(b"corrupted blob") + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=good, + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_called_once() + assert result == [int(b) for b in good] + + +def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None: + """A cached blob passing its hash check is used with zero network.""" + good = b"good firmware" + expected = _sha(good) + shd._firmware_cache_path(expected).write_bytes(good) + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content" + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_not_called() + assert result == [int(b) for b in good] + + +def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None: + """A fresh download failing its hash check raises and is not cached.""" + expected = _sha(b"expected firmware") + path = shd._firmware_cache_path(expected) + + with ( + patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"wrong firmware", + ), + pytest.raises(Invalid, match="Hash mismatch"), + ): + shd.get_firmware( + {"update": True, "url": "https://example.com/fw.bin", "sha256": expected} + ) + + assert not path.exists() + + +def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None: + """The unverifiable no-hash branch must not accept a stale copy.""" + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"fw", + ) as mock_download: + shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"}) + + assert mock_download.call_args.kwargs["allow_stale"] is False diff --git a/tests/unit_tests/test_config_prefetch.py b/tests/unit_tests/test_config_prefetch.py new file mode 100644 index 0000000000..afb93a09a0 --- /dev/null +++ b/tests/unit_tests/test_config_prefetch.py @@ -0,0 +1,355 @@ +"""Tests for the remote file prefetch validation step.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import core +from esphome.config import Config, PrefetchRemoteFilesValidationStep +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace: + return SimpleNamespace( + is_platform_component=is_platform, + prefetch_files=prefetch, + ) + + +def _run_step( + domains: dict[str, Any], + components: dict[str, Any], + platforms: dict[tuple[str, str], Any] | None = None, + download_side_effect: Any = None, +) -> tuple[Config, MagicMock]: + result = Config() + for domain, conf in domains.items(): + result[domain] = conf + with ( + patch("esphome.config.get_component", side_effect=components.get), + patch( + "esphome.config.get_platform", + side_effect=lambda d, p: (platforms or {}).get((d, p)), + ), + patch( + "esphome.external_files.download_content_many", + side_effect=download_side_effect, + ) as mock_download, + ): + PrefetchRemoteFilesValidationStep().run(result) + return result, mock_download + + +def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]: + return list(mock_download.call_args_list[call][0][0]) + + +def test_component_hook_receives_normalized_entries() -> None: + """A bare dict conf is passed to the hook as a one-entry list.""" + seen: list[Any] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("https://example.com/a", Path("/cache/a"))] + + _, mock_download = _run_step( + {"my_comp": {"key": "value"}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert seen == [[{"key": "value"}]] + mock_download.assert_called_once() + assert _downloaded(mock_download) == [ + RemoteFile("https://example.com/a", Path("/cache/a")) + ] + + +def test_platform_entries_are_grouped_per_platform() -> None: + """Platform domains route entries to each platform module's hook.""" + seen_a: list[Any] = [] + seen_b: list[Any] = [] + + def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_a.extend(entries) + yield [RemoteFile("url-a", Path("/a"))] + + def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_b.extend(entries) + yield [RemoteFile("url-b", Path("/b"))] + + entries = [ + {"platform": "a", "n": 1}, + {"platform": "b", "n": 2}, + {"platform": "a", "n": 3}, + ] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(is_platform=True)}, + platforms={ + ("image", "a"): _component(prefetch=hook_a), + ("image", "b"): _component(prefetch=hook_b), + }, + ) + + assert seen_a == [entries[0], entries[2]] + assert seen_b == [entries[1]] + assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [ + RemoteFile("url-a", Path("/a")), + RemoteFile("url-b", Path("/b")), + ] + + +def test_hook_failure_does_not_fail_validation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising hook is logged and other hooks still prefetch.""" + + def bad_hook(entries: list[dict]) -> list[RemoteFile]: + raise RuntimeError("garbage config") + + def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/g"))] + + _, mock_download = _run_step( + {"bad": {"x": 1}, "good": {"y": 2}}, + { + "bad": _component(prefetch=bad_hook), + "good": _component(prefetch=good_hook), + }, + ) + + assert "Remote file prefetch for bad failed" in caplog.text + assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))] + + +def test_stages_download_between_resumptions() -> None: + """Each yielded stage is downloaded before the generator resumes.""" + order: list[str] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + order.append("stage1") + yield [RemoteFile("css-url", Path("/css"))] + order.append("stage2") + yield [RemoteFile("ttf-url", Path("/ttf"))] + + def record_download(items: Any, description: str) -> None: + order.append(f"download:{[file.url for file in items]}") + + _, mock_download = _run_step( + {"font": {"f": 1}}, + {"font": _component(prefetch=hook)}, + download_side_effect=record_download, + ) + + assert order == [ + "stage1", + "download:['css-url']", + "stage2", + "download:['ttf-url']", + ] + assert mock_download.call_count == 2 + + +def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None: + """An endless generator stops after the stage backstop.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + n = 0 + while True: + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + n += 1 + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_mid_stage_failure_stops_only_that_hook( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator raising on a later stage does not affect other hooks.""" + + def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("first", Path("/first"))] + raise RuntimeError("stage two exploded") + + def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("one", Path("/one"))] + yield [RemoteFile("two", Path("/two"))] + + _, mock_download = _run_step( + {"flaky": {"x": 1}, "steady": {"y": 2}}, + { + "flaky": _component(prefetch=flaky_hook), + "steady": _component(prefetch=steady_hook), + }, + ) + + assert "Remote file prefetch for flaky failed" in caplog.text + assert mock_download.call_count == 2 + assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))] + + +def test_download_failure_is_swallowed() -> None: + """cv.Invalid from the batch download never escapes the step.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=cv.Invalid("download failed"), + ) + + mock_download.assert_called_once() + assert not result.errors + + +def test_domains_without_hooks_do_not_download() -> None: + """Components without PREFETCH_FILES cause no download call.""" + _, mock_download = _run_step( + {"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}}, + {"plain": _component()}, + ) + mock_download.assert_not_called() + + +def test_none_and_autoload_confs_are_skipped() -> None: + """None and AutoLoad confs never reach a hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"a": None, "b": core.AutoLoad()}, + {"a": _component(prefetch=hook), "b": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_non_dict_entries_are_ignored() -> None: + """Garbage entries never reach a component hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"my_comp": ["just-a-string", 42]}, + {"my_comp": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_platform_entries_without_platform_key_are_ignored() -> None: + """Entries with a missing or unknown platform never reach a hook.""" + _, mock_download = _run_step( + {"image": [{"n": 1}, "garbage", {"platform": "unknown"}]}, + {"image": _component(is_platform=True)}, + ) + mock_download.assert_not_called() + + +def test_generator_still_alive_at_the_cap_is_warned_and_closed( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator with a stage left at the cap is warned about and closed.""" + closed: list[bool] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + finally: + closed.append(True) + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + assert closed == [True] + + +def test_plain_iterable_hook_survives_the_cap( + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook returning a plain list of batches cannot crash the backstop.""" + + def hook(entries: list[dict]) -> list[list[RemoteFile]]: + return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)] + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_domain_level_hook_on_platform_component() -> None: + """A hook on the platform component's domain module sees all entries.""" + seen: list[Any] = [] + + def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("domain-url", Path("/domain"))] + + entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(prefetch=domain_hook, is_platform=True)}, + ) + + assert seen == [entries] + assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))] + + +def test_generator_raising_on_close_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator whose close() raises at the cap is logged, not crashed on.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + except GeneratorExit: + raise RuntimeError("close exploded") from None + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_unexpected_download_error_is_logged_visibly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken batch downloader warns instead of silently disabling prefetch.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=TypeError("not a RemoteFile"), + ) + + mock_download.assert_called_once() + assert not result.errors + assert "Remote file prefetch failed" in caplog.text diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 16cee9564f..4e993ff4f3 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -3,6 +3,7 @@ import os from pathlib import Path import time +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: @pytest.fixture def mock_requests_head() -> MagicMock: - """Patch `external_files.requests.head` so the conditional HEAD-request - validator can be tested without doing real HTTP. + """Patch `requests.head` so the conditional HEAD-request validator can + be tested without doing real HTTP. Patched on the requests module + because external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.head") as m: + with patch("requests.head") as m: yield m @pytest.fixture def mock_requests_get() -> MagicMock: - """Patch `external_files.requests.get` so the download path can be - tested without doing real HTTP. + """Patch `requests.get` so the download path can be tested without + doing real HTTP. Patched on the requests module because + external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.get") as m: + with patch("requests.get") as m: yield m @@ -549,6 +552,10 @@ def test_download_content_skip_external_update_uses_cache( assert result == cached_content mock_has_remote_file_changed.assert_not_called() mock_requests_get.assert_not_called() + # Deliberately unchecked is memoized for the run but never "fresh". + assert not external_files.is_fresh_this_run(test_file) + assert external_files.download_content(url, test_file) == cached_content + mock_has_remote_file_changed.assert_not_called() def test_download_content_skip_external_update_downloads_when_missing( @@ -587,10 +594,16 @@ def test_download_content_many_single_item_avoids_pool( mock_download_content: MagicMock, setup_core: Path ) -> None: """A single item should be downloaded inline (no thread pool overhead).""" - item = ("https://example.com/file.txt", setup_core / "f.txt") + item = external_files.RemoteFile( + "https://example.com/file.txt", setup_core / "f.txt" + ) external_files.download_content_many([item]) mock_download_content.assert_called_once_with( - item[0], item[1], external_files.NETWORK_TIMEOUT + item.url, + item.path, + external_files.NETWORK_TIMEOUT, + allow_stale=True, + return_content=False, ) @@ -602,7 +615,12 @@ def test_download_content_many_runs_in_parallel( barrier = threading.Barrier(3) - def slow_download(url: str, path: Path, timeout: int) -> bytes: + def slow_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: # If calls were serial this would deadlock (third caller never arrives # while the first is blocked at the barrier). barrier.wait(timeout=2.0) @@ -610,9 +628,9 @@ def test_download_content_many_runs_in_parallel( mock_download_content.side_effect = slow_download items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] external_files.download_content_many(items, max_workers=4) assert mock_download_content.call_count == 3 @@ -625,15 +643,20 @@ def test_download_content_many_propagates_single_error( it in a `MultipleInvalid` that the caller would have to unpack. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("bad"): raise Invalid(f"could not download {url}") return b"" mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad", setup_core / "bad"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad", setup_core / "bad"), ] with pytest.raises(Invalid, match="could not download") as exc_info: external_files.download_content_many(items) @@ -648,16 +671,21 @@ def test_download_content_many_aggregates_multiple_errors( them one network round-trip at a time. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("ok"): return b"" raise Invalid(f"could not download {url}") mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad1", setup_core / "bad1"), - ("https://example.com/bad2", setup_core / "bad2"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"), + external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"), ] with pytest.raises(MultipleInvalid) as exc_info: external_files.download_content_many(items) @@ -678,9 +706,9 @@ def test_download_content_many_dedupes_by_path( """ path = setup_core / "shared" items = [ - ("https://example.com/a", path), - ("https://example.com/b", path), - ("https://example.com/a", path), + external_files.RemoteFile("https://example.com/a", path), + external_files.RemoteFile("https://example.com/b", path), + external_files.RemoteFile("https://example.com/a", path), ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 @@ -695,8 +723,8 @@ def test_download_content_many_clamps_invalid_max_workers( be clamped up to at least 1 worker. """ items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) assert mock_download_content.call_count == 2 @@ -724,8 +752,8 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_download_content_many.assert_called_once() assert list(mock_download_content_many.call_args[0][0]) == [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] @@ -799,3 +827,264 @@ def test_download_content_atomic_write_no_partial_on_failure( # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] + + +def test_download_content_memoizes_fresh_path( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A path downloaded once this run skips all network on later calls.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"fresh content" + assert external_files.download_content(url, test_file) == b"fresh content" + + mock_has_remote_file_changed.assert_called_once() + mock_requests_get.assert_called_once() + + +def test_download_content_memo_revalidates_deleted_file( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A memoized path whose file vanished is downloaded again.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + test_file.unlink() + external_files.download_content(url, test_file) + + assert mock_requests_get.call_count == 2 + + +def test_download_content_failure_fails_fast_on_retry( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A failed download is remembered; a retry raises without network.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + + mock_requests_get.assert_called_once() + + +def test_download_content_failed_path_revalidates_when_file_appears( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A recorded failure is dropped once the file exists on disk.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid): + external_files.download_content(url, test_file) + + # Another writer produced the file; the cached failure no longer applies + # and the network error now falls back to the on-disk copy. + test_file.write_bytes(b"appeared") + assert external_files.download_content(url, test_file) == b"appeared" + + +def test_download_content_network_error_fallback_memoizes( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Falling back to a cached file memoizes, so a flaky host is hit once.""" + test_file = setup_core / "memo.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_called_once() + + +def test_download_content_not_changed_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A 304 not-changed check serves the cached file without a GET.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + mock_has_remote_file_changed.return_value = False + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_not_called() + + +def test_head_failure_fallback_is_stale_not_fresh( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A HEAD network failure serves the copy once and memoizes it as stale.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_head.assert_called_once() + mock_requests_get.assert_not_called() + + +def test_allow_stale_false_rejects_unverified_copy( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False raises instead of building from an unverified copy.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + + # A strict caller gets its own attempt at the network rather than + # inheriting the stale memo's verdict. + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + assert mock_requests_get.call_count == 2 + + # A caller that tolerates stale copies still gets the cached bytes. + assert external_files.download_content(url, test_file) == b"cached content" + + +def test_allow_stale_false_rejects_head_failure_fallback( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False also rejects a copy the HEAD could not confirm.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="cannot be verified"): + external_files.download_content(url, test_file, allow_stale=False) + mock_requests_get.assert_not_called() + + +def test_download_content_many_forwards_per_file_allow_stale( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Each RemoteFile's own allow_stale reaches download_content.""" + files = [ + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile( + "https://example.com/b", setup_core / "b", allow_stale=False + ), + ] + external_files.download_content_many(files) + forwarded = { + call.args[1]: call.kwargs["allow_stale"] + for call in mock_download_content.call_args_list + } + assert forwarded == {setup_core / "a": True, setup_core / "b": False} + + +def test_download_content_many_dedupe_keeps_strictest( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A strict duplicate wins over a permissive one for the same path.""" + path = setup_core / "fw.bin" + files = [ + external_files.RemoteFile("https://example.com/fw", path, allow_stale=False), + external_files.RemoteFile("https://example.com/fw", path), + ] + external_files.download_content_many(files) + mock_download_content.assert_called_once() + assert mock_download_content.call_args.kwargs["allow_stale"] is False + + +def test_successful_head_revalidation_clears_stale( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A confirmed 304 supersedes an earlier failed revalidation.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + ok_304 = MagicMock(status_code=304, headers={}) + mock_requests_head.side_effect = [ + requests.exceptions.RequestException("blip"), + ok_304, + ] + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + # The stale memo short-circuits tolerant callers; a strict caller + # triggers a fresh HEAD, which now succeeds and clears the marker. + assert ( + external_files.download_content(url, test_file, allow_stale=False) + == b"cached content" + ) + # Verified now: served from the fresh memo with no more network. + assert external_files.download_content(url, test_file) == b"cached content" + assert mock_requests_head.call_count == 2 + mock_requests_get.assert_not_called() + + +def test_failed_path_replay_names_the_other_url( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A shared cache path replays the failure naming the original URL.""" + test_file = setup_core / "shared.bin" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + with pytest.raises(Invalid, match="first-url"): + external_files.download_content("https://example.com/first-url", test_file) + with pytest.raises(Invalid, match="earlier download of.*first-url"): + external_files.download_content("https://example.com/second-url", test_file) + mock_requests_get.assert_called_once() From 3f490fe1ed023e8ac31b2a757f5d6415040d8d59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:42:05 +1200 Subject: [PATCH 004/470] [internal_temperature] Read the RP2 on-die sensor directly instead of via the Arduino API (#18262) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../internal_temperature_rp2.cpp | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc3..2e408b3b01 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.h" +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; - temperature = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { From 22153be4cda5f4817df44c99afdff30d03b255ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 05:00:07 -0500 Subject: [PATCH 005/470] [core] Add esphome logs over web_server HTTP SSE (#17110) --- esphome/__main__.py | 65 +++- esphome/helpers.py | 18 + esphome/web_server_helpers.py | 43 +++ esphome/web_server_logs.py | 189 ++++++++++ esphome/web_server_ota.py | 19 +- tests/unit_tests/test_helpers.py | 18 +- tests/unit_tests/test_main.py | 133 +++++++ tests/unit_tests/test_web_server_helpers.py | 64 ++++ tests/unit_tests/test_web_server_logs.py | 397 ++++++++++++++++++++ tests/unit_tests/test_web_server_ota.py | 14 +- 10 files changed, 919 insertions(+), 41 deletions(-) create mode 100644 esphome/web_server_helpers.py create mode 100644 esphome/web_server_logs.py create mode 100644 tests/unit_tests/test_web_server_helpers.py create mode 100644 tests/unit_tests/test_web_server_logs.py diff --git a/esphome/__main__.py b/esphome/__main__.py index c4ba6b54d7..0ac5898268 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -21,7 +21,6 @@ from esphome.const import ( ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -29,6 +28,7 @@ from esphome.const import ( CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -42,7 +42,7 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, @@ -273,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "configured. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, or view logs over USB." ) if purpose == Purpose.UPLOADING and not has_ota(): return ( @@ -314,9 +314,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -328,7 +331,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -390,7 +397,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -483,6 +490,21 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" if CONF_MQTT not in CORE.config: @@ -1291,25 +1313,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1437,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") diff --git a/esphome/helpers.py b/esphome/helpers.py index 15d9797ce1..2731109164 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -357,6 +357,24 @@ def resolve_ip_address( return res +def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str: + """Build an ``http://host:port/path`` URL for a resolved address. + + ``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6 + literals must be wrapped in brackets in URLs; link-local addresses need a + percent-encoded zone index per RFC 6874. + """ + import socket + + ip = sockaddr[0] + if family == socket.AF_INET6: + scope = sockaddr[3] if len(sockaddr) >= 4 else 0 + host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" + else: + host_part = ip + return f"http://{host_part}:{port}{path}" + + def sort_ip_addresses(address_list: list[str]) -> list[str]: """Takes a list of IP addresses in string form, e.g. from mDNS or MQTT, and sorts them into the best order to actually try connecting to them. diff --git a/esphome/web_server_helpers.py b/esphome/web_server_helpers.py new file mode 100644 index 0000000000..f48934b185 --- /dev/null +++ b/esphome/web_server_helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for the web_server HTTP transports (OTA upload and logs).""" + +from __future__ import annotations + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import format_ip_url, resolve_ip_address +from esphome.types import ConfigType + + +def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]: + """Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``. + + Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and + formats each resolved address into an ``http://host:port/path`` URL via + :func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the + web_server OTA upload and log streaming paths. + """ + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + return [ + (sockaddr[0], format_ip_url(family, sockaddr, port, path)) + for family, _socktype, _, _, sockaddr in addr_infos + ] + + +def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]: + """Return ``(port, username, password)`` for the web_server HTTP endpoint. + + Reads the port and optional HTTP Basic-auth credentials from the validated + ``web_server:`` config, shared by the web_server OTA upload and log + streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent. + """ + web_conf = config.get(CONF_WEB_SERVER) + if not web_conf: + raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.") + auth = web_conf.get(CONF_AUTH) or {} + return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD) diff --git a/esphome/web_server_logs.py b/esphome/web_server_logs.py new file mode 100644 index 0000000000..e091e24bb7 --- /dev/null +++ b/esphome/web_server_logs.py @@ -0,0 +1,189 @@ +"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint. + +The ``web_server`` component exposes a Server-Sent Events stream at ``/events`` +that multiplexes entity state, keepalive pings, and log lines (``event: log``). +This is the logging counterpart to the web_server OTA upload path +(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that +has ``web_server:`` configured but no ``api:``. + +Only the ``event: log`` frames are rendered; the payload is the device's +already-formatted, ANSI-colored log line, so it is passed through the same +``LogParser`` + ``safe_print`` path the serial and native-API log viewers use. +The stream is long-lived and the server drops idle connections, so the reader +reconnects automatically until interrupted. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +import time +from typing import TYPE_CHECKING + +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import EsphomeError +from esphome.util import safe_print +from esphome.web_server_helpers import resolve_web_server_urls + +if TYPE_CHECKING: + from aioesphomeapi import LogParser + +_LOGGER = logging.getLogger(__name__) + +EVENTS_PATH = "/events" +# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every +# 10s, so a 30s read timeout tolerates a few missed pings before we treat the +# connection as dead and reconnect. +TIMEOUT = (10.0, 30.0) +# Pause between reconnect attempts so a downed device doesn't spin the CPU. +RECONNECT_DELAY = 1.0 +# Upper bound for the exponential backoff applied to consecutive failures, so an +# unreachable host backs off instead of retrying (and logging) once a second. +MAX_RECONNECT_DELAY = 10.0 + + +class WebServerLogsError(EsphomeError): + """Raised when the web_server log stream cannot be used (e.g. bad auth).""" + + +def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]: + """Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint.""" + urls: list[tuple[str, str]] = [] + seen: set[str] = set() + for host in hosts: + try: + resolved = resolve_web_server_urls(host, port, EVENTS_PATH) + except EsphomeError as err: + _LOGGER.warning("Error resolving IP address of %s: %s", host, err) + continue + for ip, url in resolved: + if url not in seen: + seen.add(url) + urls.append((ip, url)) + return urls + + +def _emit(data_lines: list[str], parser: LogParser) -> None: + """Render the accumulated ``data:`` lines of one ``event: log`` frame.""" + time_ = datetime.now().astimezone() + milliseconds = time_.microsecond // 1000 + time_str = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" + ) + for line in data_lines: + safe_print(parser.parse_line(line, time_str)) + + +def _consume(response: requests.Response, parser: LogParser) -> None: + """Parse the SSE stream, rendering only ``event: log`` frames. + + Implements the minimal slice of the SSE grammar the ``web_server`` stream + uses: ``field: value`` lines (with one optional leading space after the + colon) accumulated until a blank line dispatches the frame. ``id:``, + ``retry:``, and comment (``:``) lines are ignored, as are non-``log`` + events (``ping``, ``state``, ...). + """ + event_type = "message" + data_lines: list[str] = [] + # Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the + # text/event-stream response has no charset, so requests' decode_unicode + # would fall back to Latin-1 and mojibake UTF-8 log characters. + for raw in response.iter_lines(): + line = raw.decode("utf8", "backslashreplace") + if not line: + if event_type == "log" and data_lines: + _emit(data_lines, parser) + event_type = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event_type = value + elif field == "data": + data_lines.append(value) + + +def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool: + """Connect and stream one session. + + Returns ``True`` if a connection was established (even if it later + dropped), ``False`` if the connection attempt itself failed so the caller + can try the next resolved address. + """ + connected = False + _LOGGER.info("Connecting to %s ...", url) + try: + with requests.get( + url, + stream=True, + auth=auth, + timeout=TIMEOUT, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code == 401: + raise WebServerLogsError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code in (403, 404): + # Permanent: the endpoint won't appear on retry (wrong version, + # 'log' disabled, or forbidden). Surface it instead of looping. + raise WebServerLogsError( + f"Device returned HTTP {response.status_code} for " + f"{EVENTS_PATH}; the web_server log stream is unavailable. " + "Ensure 'web_server' is version 2 or higher with 'log' enabled." + ) + if response.status_code != 200: + _LOGGER.error( + "Unexpected HTTP %s response from %s", response.status_code, ip + ) + return False + connected = True + _LOGGER.info("Connected to %s", ip) + _consume(response, parser) + except requests.RequestException as err: + if connected: + _LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err) + else: + _LOGGER.warning("Could not connect to %s: %s", ip, err) + return connected + + +def run_logs( + hosts: list[str], + port: int, + username: str | None, + password: str | None, +) -> int: + """Stream logs from the first reachable host over the web_server SSE feed. + + Reconnects automatically when the stream drops and returns ``0`` on + ``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits. + """ + from aioesphomeapi import LogParser + + auth = HTTPBasicAuth(username, password) if username and password else None + parser = LogParser() + delay = RECONNECT_DELAY + try: + while True: + if not (urls := _build_urls(hosts, port)): + _LOGGER.error("Could not resolve any of: %s", ", ".join(hosts)) + connected = False + else: + # ``any`` stops at the first address that connects; when that + # stream drops we reconnect to the same set on the next pass. + connected = any(_stream(url, ip, auth, parser) for ip, url in urls) + # Reset the backoff once we reach the device; otherwise grow it + # (capped) so an unreachable host doesn't retry/log once a second. + delay = ( + RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY) + ) + time.sleep(delay) + except KeyboardInterrupt: + return 0 diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 8d0fdeecff..7b508e8527 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -12,14 +12,14 @@ import io import logging from pathlib import Path import secrets -import socket from typing import BinaryIO import requests from requests.auth import HTTPBasicAuth from esphome.core import EsphomeError -from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.helpers import ProgressBar +from esphome.web_server_helpers import resolve_web_server_urls _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def _try_upload( from esphome.core import CORE try: - addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + addr_urls = resolve_web_server_urls(host, port, OTA_PATH) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", host @@ -104,7 +104,7 @@ def _try_upload( _LOGGER.error("(If you know the IP, try --device )") raise WebServerOTAError(err) from err - if not addr_infos: + if not addr_urls: _LOGGER.error("Could not resolve %s", host) return 1, None @@ -113,16 +113,7 @@ def _try_upload( auth = HTTPBasicAuth(username, password) if username and password else None # Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does. - for af, _socktype, _, _, sa in addr_infos: - ip = sa[0] - # IPv6 literals must be wrapped in brackets in URLs; link-local - # addresses need a percent-encoded zone index per RFC 6874. - if af == socket.AF_INET6: - scope = sa[3] if len(sa) >= 4 else 0 - host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" - else: - host_part = ip - url = f"http://{host_part}:{port}{OTA_PATH}" + for ip, url in addr_urls: _LOGGER.info("Connecting to %s port %s...", ip, port) try: diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 211fbf5112..6e00e5b80f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -14,7 +14,7 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache from esphome.core import CORE, EsphomeError -from esphome.helpers import ProgressBar +from esphome.helpers import ProgressBar, format_ip_url @pytest.mark.parametrize( @@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host): assert actual is False +@pytest.mark.parametrize( + ("family", "sockaddr", "expected"), + ( + (socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"), + (socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"), + ( + socket.AF_INET6, + ("fe80::1", 8080, 0, 7), + "http://[fe80::1%257]:8080/events", + ), + ), +) +def test_format_ip_url(family, sockaddr, expected): + assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected + + @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 14b49a1a05..23bfdbcd69 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -52,6 +52,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_logging, has_web_server_ota, mqtt_get_ip, parse_args, @@ -80,6 +81,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -94,6 +96,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -816,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_web_server_only_ip() -> None: + """A web_server-only device with a static IP resolves to that IP for logs.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_logging_web_server_only_mdns() -> None: + """A web_server-only device with a .local name resolves to that hostname.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="test.local") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["test.local"] + + def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: """A resolvable device with only ota: fails logs with a missing-api message.""" setup_core( @@ -855,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: assert "set 'use_address'" in msg +def test_unresolved_default_error_logging_suggests_web_server() -> None: + """The missing-api log message lists web_server among the remediations.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "no 'api:' component is configured" in msg + assert "'web_server:'" in msg + + def test_unresolved_default_error_upload_with_ota_is_generic() -> None: """With ota: present the upload error stays generic, not transport-specific.""" setup_core( @@ -2534,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None: assert has_ota() is True +def test_has_web_server_logging_default() -> None: + """has_web_server_logging is True for a default web_server (v2, log on).""" + setup_core(config={CONF_WEB_SERVER: {}}) + assert has_web_server_logging() is True + + +def test_has_web_server_logging_without_config() -> None: + """has_web_server_logging is False when web_server is not configured.""" + setup_core(config={CONF_API: {}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_v1_has_no_events_stream() -> None: + """has_web_server_logging is False for v1, which has no /events endpoint.""" + setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_respects_log_disabled() -> None: + """has_web_server_logging is False when the web_server log option is off.""" + setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}}) + assert has_web_server_logging() is False + + def test_upload_program_web_server_only_auto_dispatches( mock_run_web_server_ota: Mock, mock_run_ota: Mock, @@ -3102,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only( ) +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server( + mock_run_logs: Mock, +) -> None: + """A web_server-only device streams logs over the HTTP SSE endpoint.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None) + + +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server_with_auth_and_port( + mock_run_logs: Mock, +) -> None: + """web_server port and basic-auth credentials are forwarded to the streamer.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + }, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret") + + +@patch("esphome.web_server_logs.run_logs") +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt_preferred_over_web_server( + mock_mqtt_show_logs: Mock, + mock_run_logs: Mock, +) -> None: + """With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server).""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + result = show_logs(CORE.config, args, ["192.168.1.100"]) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once() + mock_run_logs.assert_not_called() + + def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( diff --git a/tests/unit_tests/test_web_server_helpers.py b/tests/unit_tests/test_web_server_helpers.py new file mode 100644 index 0000000000..0280630d69 --- /dev/null +++ b/tests/unit_tests/test_web_server_helpers.py @@ -0,0 +1,64 @@ +"""Unit tests for esphome.web_server_helpers module.""" + +from __future__ import annotations + +import socket + +import pytest + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import EsphomeError +from esphome.web_server_helpers import ( + get_web_server_connection, + resolve_web_server_urls, +) + + +def test_resolve_web_server_urls_maps_ipv4_and_ipv6( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each resolved address becomes an (ip, url) pair with IPv6 bracketing.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)), + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)), + ] + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + assert resolve_web_server_urls("dev.local", 80, "/events") == [ + ("192.168.1.5", "http://192.168.1.5:80/events"), + ("fe80::1", "http://[fe80::1%257]:80/events"), + ] + + +def test_get_web_server_connection_without_auth() -> None: + """Port is returned and credentials are None when no auth is configured.""" + config = {CONF_WEB_SERVER: {CONF_PORT: 80}} + + assert get_web_server_connection(config) == (80, None, None) + + +def test_get_web_server_connection_with_auth() -> None: + """Port and HTTP Basic credentials are returned when auth is configured.""" + config = { + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + } + } + + assert get_web_server_connection(config) == (8080, "admin", "secret") + + +def test_get_web_server_connection_missing_component() -> None: + """A config without web_server raises a clear error.""" + with pytest.raises(EsphomeError, match="web_server.*not configured"): + get_web_server_connection({}) diff --git a/tests/unit_tests/test_web_server_logs.py b/tests/unit_tests/test_web_server_logs.py new file mode 100644 index 0000000000..bbdf37bed7 --- /dev/null +++ b/tests/unit_tests/test_web_server_logs.py @@ -0,0 +1,397 @@ +"""Unit tests for esphome.web_server_logs module.""" + +from __future__ import annotations + +from collections.abc import Iterator +import logging +import socket +from typing import Self +from unittest.mock import MagicMock + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome import web_server_logs +from esphome.core import EsphomeError +from esphome.web_server_logs import ( + EVENTS_PATH, + WebServerLogsError, + _build_urls, + _consume, + _stream, + run_logs, +) + +# A realistic slice of the web_server /events SSE stream: an initial ping +# carrying the config, a state frame, two log frames (one multi-line), plus +# comment/id/retry lines that must be ignored. +SSE_LINES = [ + "retry: 30000", + "id: 12345", + "event: ping", + 'data: {"title":"dev","log":true}', + "", + "event: state", + 'data: {"id":"sensor-x","state":"ON"}', + "", + "event: log", + "data: \x1b[0;32m[I][main:001]: hello\x1b[0m", + "", + ": keepalive-comment", + "event: log", + "data: line one", + "data: line two", + "", +] + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response.""" + + def __init__(self, status_code: int, lines: list[str]) -> None: + self.status_code = status_code + self._lines = lines + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def iter_lines(self) -> Iterator[bytes]: + for line in self._lines: + yield line.encode("utf8") + + +@pytest.fixture +def fake_parser() -> MagicMock: + """A LogParser whose parse_line returns the raw line unchanged.""" + parser = MagicMock() + parser.parse_line.side_effect = lambda line, time_str: line + return parser + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, + addr_infos: list[tuple[int, int, int, str, tuple]], +) -> None: + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + +# --------------------------------------------------------------------------- +# _build_urls +# --------------------------------------------------------------------------- + + +def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None: + """An IPv4 host resolves to a plain http://ip:port/events URL.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))], + ) + + assert _build_urls(["dev.local"], 80) == [ + ("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}") + ] + + +def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 literals are bracketed; link-local addresses get a %25 zone index.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))], + ) + + assert _build_urls(["dev.local"], 8080) == [ + ("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}") + ] + + +def test_build_urls_dedups_and_skips_unresolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate resolved IPs collapse to one URL; resolve errors are skipped.""" + calls: list[str] = [] + + def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]: + calls.append(host) + if host == "bad": + raise EsphomeError("nope") + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))] + + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve) + + # "good" and "dup" both resolve to 10.0.0.1, "bad" raises. + assert _build_urls(["good", "bad", "dup"], 80) == [ + ("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}") + ] + assert calls == ["good", "bad", "dup"] + + +# --------------------------------------------------------------------------- +# _consume (SSE parsing) +# --------------------------------------------------------------------------- + + +def test_consume_emits_only_log_frames( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """Only event: log data lines are printed; ping/state/comments are ignored.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, SSE_LINES), fake_parser) + + assert printed == [ + "\x1b[0;32m[I][main:001]: hello\x1b[0m", + "line one", + "line two", + ] + + +def test_consume_ignores_unterminated_trailing_frame( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """A log frame without its terminating blank line is not emitted.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser) + + assert printed == [] + + +# --------------------------------------------------------------------------- +# _stream +# --------------------------------------------------------------------------- + + +def test_stream_returns_false_when_connect_fails( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed connection logs a warning and reports not-connected.""" + + def boom(*args: object, **kwargs: object) -> _FakeResponse: + raise requests.ConnectionError("refused") + + monkeypatch.setattr(requests, "get", boom) + + with caplog.at_level(logging.WARNING): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False + ) + assert "Could not connect to 10.0.0.1" in caplog.text + + +def test_stream_returns_true_when_established_then_dropped( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A mid-stream drop after connecting reports connected so we reconnect.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + class _DroppingResponse(_FakeResponse): + def iter_lines(self) -> Iterator[bytes]: + yield b"event: log" + yield b"data: before-drop" + yield b"" + raise requests.exceptions.ChunkedEncodingError("connection lost") + + monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, [])) + + with caplog.at_level(logging.INFO): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True + ) + assert printed == ["before-drop"] + assert "reconnecting" in caplog.text + + +# --------------------------------------------------------------------------- +# run_logs +# --------------------------------------------------------------------------- + + +def test_run_logs_streams_then_reconnects_until_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dropped stream reconnects; KeyboardInterrupt during the pause exits 0.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES)) + + def stop(_delay: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", stop) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # The single stream was consumed before the reconnect pause interrupted us. + # run_logs renders through the real LogParser, which prefixes a timestamp, + # so assert on the payloads rather than exact equality. + assert len(printed) == 3 + assert "[I][main:001]: hello" in printed[0] + assert "line one" in printed[1] + assert "line two" in printed[2] + + +def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """Username + password are forwarded as HTTP Basic auth on the request.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + captured["url"] = url + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, "admin", "secret") == 0 + auth = captured["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert (auth.username, auth.password) == ("admin", "secret") + assert captured["stream"] is True + assert captured["headers"] == {"Accept": "text/event-stream"} + + +def test_run_logs_no_auth_when_credentials_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auth object is sent when username/password are not configured.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, None, None) == 0 + assert captured["auth"] is None + + +def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 aborts with a clear error rather than reconnecting forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, [])) + + with pytest.raises(WebServerLogsError, match="Authentication failed"): + run_logs(["dev.local"], 80, "admin", "bad") + + +def test_run_logs_retries_on_transient_status( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A transient non-200 (e.g. 503) is logged and the loop retries.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, [])) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert "Unexpected HTTP 503" in caplog.text + + +@pytest.mark.parametrize("status", (403, 404)) +def test_run_logs_raises_on_permanent_status( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """A permanent 403/404 aborts instead of retrying the endpoint forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, [])) + + with pytest.raises(WebServerLogsError, match=str(status)): + run_logs(["dev.local"], 80, None, None) + + +def test_run_logs_backs_off_on_repeated_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive unreachable attempts grow the reconnect delay up to the cap.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + delays: list[float] = [] + + def record(delay: float) -> None: + delays.append(delay) + if len(delays) >= 4: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", record) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0). + assert delays == [2.0, 4.0, 8.0, 10.0] + + +def test_run_logs_reports_unresolvable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """When no host resolves, an error is logged and the loop pauses/retries.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + + # Let the first reconnect pause pass so the loop continues, then interrupt + # on the second so the retry path (the ``continue``) is exercised. + sleeps = {"n": 0} + + def sleep(_delay: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 2: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", sleep) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert sleeps["n"] == 2 + assert "Could not resolve" in caplog.text diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py index 606905e36e..bde04f4db7 100644 --- a/tests/unit_tests/test_web_server_ota.py +++ b/tests/unit_tests/test_web_server_ota.py @@ -46,7 +46,7 @@ def _patch_resolve( for host, port in hosts ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) @@ -475,7 +475,7 @@ def test_run_ota_resolution_failure( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) monkeypatch.setattr(CORE, "dashboard", True) try: exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) with patch( "esphome.web_server_ota.requests.post", @@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) @@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( @@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( From 3e4661fe1e96a3546fa53d9fa4b00db8c26c4c83 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:42 +1200 Subject: [PATCH 006/470] Bump version to 2026.8.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..006f97acb7 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.8.0-dev +PROJECT_NUMBER = 2026.8.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 a3e9f47909..623d9673bc 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.8.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8a1aa5753d45c9819940b9cbaba2f0897c6f16cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:42 +1200 Subject: [PATCH 007/470] Bump version to 2026.9.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..8f6048b4d8 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.8.0-dev +PROJECT_NUMBER = 2026.9.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 a3e9f47909..0dd948544f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.9.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3c46cc9c3572d4bf70026559d95eb96f49096759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 09:59:24 -0500 Subject: [PATCH 008/470] [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) --- esphome/components/usb_uart/pl2303.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // 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]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " 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); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); From 1a01c34ec4fed020264e69e78f08ad3f29af8bad Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:33:45 +0000 Subject: [PATCH 009/470] Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..85a0f55263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From f2121130f971b63fd6776dcd00d8befe0fea2aee Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 12 Aug 2026 12:49:54 -0400 Subject: [PATCH 010/470] [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 99677390e04468438ec2a908bf431a6f16a63bae Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:27:14 -0500 Subject: [PATCH 011/470] Bump bundled esphome-device-builder to 1.9.6 (#18328) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..d7ae2cd4ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 RUN \ platformio settings set enable_telemetry No \ From 905485b6738213f33e1663ae3ab56cf8a3281289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 18:49:34 -0500 Subject: [PATCH 012/470] [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) --- esphome/components/ld2420/ld2420.cpp | 9 ++++++++- esphome/components/ld2420/ld2420.h | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); From 787a909aa49df808ed1d55f77d9dea0e60e6e4ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:11:32 -0500 Subject: [PATCH 013/470] [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) --- esphome/__main__.py | 144 +++++++++---- esphome/api_client.py | 87 +++++++- esphome/mqtt.py | 91 ++++++-- tests/unit_tests/test_api_client.py | 323 +++++++++++++++++++++++++++- tests/unit_tests/test_main.py | 171 +++++++++++---- tests/unit_tests/test_mqtt.py | 262 ++++++++++++++++++++++ 6 files changed, 982 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0ac5898268..1262a4525e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -567,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -604,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1438,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # A top-level ``deep_sleep:`` block means the device is only awake # briefly; cap the reconnect backoff so a wake window is not missed. deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 23bfdbcd69..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -3015,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) From 8e624b4117ab7c7cec32b6303efaa9cfe50462cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:14:50 -0500 Subject: [PATCH 014/470] [core] Retry framework downloads on transient network errors (#18330) --- esphome/framework_helpers.py | 259 ++++++++++++++------- tests/unit_tests/test_framework_helpers.py | 201 +++++++++++++++- 2 files changed, 373 insertions(+), 87 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..86d5e4eaea 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) # Attempts per mirror URL before falling through to the next mirror; only # mid-stream drops retry (resuming when the server gave a validator), -# connect errors move on immediately. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -887,37 +891,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - 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 + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - Returns: - The source URL. - Mirror URL templates that reference a substitution not present in - ``substitutions`` are skipped, so callers can offer templates that only - apply to some downloads. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - A path target downloads through ``download_with_resume``, so an - interrupted download resumes on the next esphome run; a file-like target - only resumes mid-stream drops within this call. - - Raises: - ValueError: If mirrors list is empty. - EsphomeError: If all download attempts fail; the message lists every - attempted URL with its individual failure reason. Also raised if - no template matched the provided substitutions. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +943,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 1. Classify the target: filesystem path or open file object - path_target: Path | None = None - f: IO[bytes] | None = None - if isinstance(target, (str, os.PathLike)): - path_target = Path(target) - 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 - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] - - for mirror in mirrors: - # 3. Apply substitutions to URL - try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) - skipped.append((mirror, f"skipped ({e!r})")) - continue - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +968,14 @@ def download_from_mirrors( failures.append((url, e)) continue - # 4. Download; mid-stream failures retry the same mirror with - # resume (see download_with_resume) instead of starting over. - # There is no checksum to verify a resumed file against, so a - # stitch is only trusted when the server proves consistency: the - # If-Range validator guarantees 206 only for unchanged content, - # and the expected total length (when the first response carried - # one) guards against short or shifted bodies. Without a - # validator the retry restarts from zero. + # File-like targets download here; mid-stream failures retry the + # same mirror with resume (see download_with_resume) instead of + # starting over. There is no checksum to verify a resumed file + # against, so a stitch is only trusted when the server proves + # consistency: the If-Range validator guarantees 206 only for + # unchanged content, and the expected total length (when the first + # response carried one) guards against short or shifted bodies. + # Without a validator the retry restarts from zero. offset = 0 expected_total = 0 validator = None @@ -1001,9 +983,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1016,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1039,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 6. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures + return None + + +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. + + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + 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. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(mirror.format(**substitutions)) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..7451ee9b39 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -515,16 +516,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1427,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. From 7569a7b5ced61c4a2826fac165e64fceee1baccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 09:59:24 -0500 Subject: [PATCH 015/470] [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) --- esphome/components/usb_uart/pl2303.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // 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]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " 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); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); From 89489b1f0dabee05c18ce8327386dd4a660d79a5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:33:45 +0000 Subject: [PATCH 016/470] Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..85a0f55263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 83cff59fddca496cdb60d66d1ea8525c62c620f6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 12 Aug 2026 12:49:54 -0400 Subject: [PATCH 017/470] [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 48d6368ff9e4b3ba18b663eb84f686e5ee84065e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:27:14 -0500 Subject: [PATCH 018/470] Bump bundled esphome-device-builder to 1.9.6 (#18328) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..d7ae2cd4ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 RUN \ platformio settings set enable_telemetry No \ From a14ea0e8fabce6ad71a3386dd5fa1ecb9edb1166 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 18:49:34 -0500 Subject: [PATCH 019/470] [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) --- esphome/components/ld2420/ld2420.cpp | 9 ++++++++- esphome/components/ld2420/ld2420.h | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); From c1a326f32e710d26e0671c56b1a74e85a37a5822 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:11:32 -0500 Subject: [PATCH 020/470] [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) --- esphome/__main__.py | 144 +++++++++---- esphome/api_client.py | 87 +++++++- esphome/mqtt.py | 91 ++++++-- tests/unit_tests/test_api_client.py | 323 +++++++++++++++++++++++++++- tests/unit_tests/test_main.py | 171 +++++++++++---- tests/unit_tests/test_mqtt.py | 262 ++++++++++++++++++++++ 6 files changed, 982 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0ac5898268..1262a4525e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -567,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -604,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1438,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # A top-level ``deep_sleep:`` block means the device is only awake # briefly; cap the reconnect backoff so a wake window is not missed. deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 23bfdbcd69..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -3015,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) From bd58b5c8b31fb97618335e163170c12beb2e1c4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:14:50 -0500 Subject: [PATCH 021/470] [core] Retry framework downloads on transient network errors (#18330) --- esphome/framework_helpers.py | 259 ++++++++++++++------- tests/unit_tests/test_framework_helpers.py | 201 +++++++++++++++- 2 files changed, 373 insertions(+), 87 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..86d5e4eaea 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) # Attempts per mirror URL before falling through to the next mirror; only # mid-stream drops retry (resuming when the server gave a validator), -# connect errors move on immediately. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -887,37 +891,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - 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 + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - Returns: - The source URL. - Mirror URL templates that reference a substitution not present in - ``substitutions`` are skipped, so callers can offer templates that only - apply to some downloads. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - A path target downloads through ``download_with_resume``, so an - interrupted download resumes on the next esphome run; a file-like target - only resumes mid-stream drops within this call. - - Raises: - ValueError: If mirrors list is empty. - EsphomeError: If all download attempts fail; the message lists every - attempted URL with its individual failure reason. Also raised if - no template matched the provided substitutions. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +943,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 1. Classify the target: filesystem path or open file object - path_target: Path | None = None - f: IO[bytes] | None = None - if isinstance(target, (str, os.PathLike)): - path_target = Path(target) - 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 - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] - - for mirror in mirrors: - # 3. Apply substitutions to URL - try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) - skipped.append((mirror, f"skipped ({e!r})")) - continue - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +968,14 @@ def download_from_mirrors( failures.append((url, e)) continue - # 4. Download; mid-stream failures retry the same mirror with - # resume (see download_with_resume) instead of starting over. - # There is no checksum to verify a resumed file against, so a - # stitch is only trusted when the server proves consistency: the - # If-Range validator guarantees 206 only for unchanged content, - # and the expected total length (when the first response carried - # one) guards against short or shifted bodies. Without a - # validator the retry restarts from zero. + # File-like targets download here; mid-stream failures retry the + # same mirror with resume (see download_with_resume) instead of + # starting over. There is no checksum to verify a resumed file + # against, so a stitch is only trusted when the server proves + # consistency: the If-Range validator guarantees 206 only for + # unchanged content, and the expected total length (when the first + # response carried one) guards against short or shifted bodies. + # Without a validator the retry restarts from zero. offset = 0 expected_total = 0 validator = None @@ -1001,9 +983,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1016,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1039,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 6. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures + return None + + +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. + + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + 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. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(mirror.format(**substitutions)) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..7451ee9b39 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -515,16 +516,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1427,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. From d3e27054f6f621e712d85e1bba4172ea6d7444c5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:30:47 +1200 Subject: [PATCH 022/470] Bump version to 2026.8.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 006f97acb7..a8c77f4bb8 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.8.0b1 +PROJECT_NUMBER = 2026.8.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 623d9673bc..b6770d0001 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b1" +__version__ = "2026.8.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9cc05b30d401daba19a061fe610a952590987961 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 21:09:01 -0500 Subject: [PATCH 023/470] [web_server_base] Stop deleting the web server on captive portal teardown (#18324) --- .../web_server/ota/ota_web_server.cpp | 2 +- .../web_server_base/web_server_base.h | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } From f337d0acff4dfae8e09021508f2bfdff070d2903 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:13:29 +0000 Subject: [PATCH 024/470] Bump pylint from 4.0.6 to 4.0.7 (#18320) 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> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0905fe6be1..1832ffd433 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.6 +pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From f1c40867783064dc711be70c3412d0066a80c378 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 13 Aug 2026 13:36:11 +0200 Subject: [PATCH 025/470] [const] Move CONF_SLOT to components/const (#18350) Co-authored-by: Oliver Kleinecke --- esphome/components/const/__init__.py | 1 + esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/sendspin/image/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..2d02c7d179 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -35,6 +35,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" +CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index b15ae53711..c6a714aace 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -33,7 +33,6 @@ CONF_DATA_READY_PIN = "data_ready_pin" CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" -CONF_SLOT = "slot" CONF_SPI_MODE = "spi_mode" # Shared fields for both transport modes diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 94d6e7cfca..3c6c82b009 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -3,6 +3,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image +from esphome.components.const import CONF_SLOT from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata import esphome.config_validation as cv from esphome.const import ( @@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767 MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) -CONF_SLOT = "slot" CONF_CURRENT_IMAGE = "current_image" CONF_TRANSITION_IMAGE = "transition_image" CONF_ON_IMAGE_DISPLAY = "on_image_display" From 87045ab9c020e95da9264c26391ffbf2bc5a4438 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:14 -0500 Subject: [PATCH 026/470] Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85a0f55263..683008400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From c7940382a9a210226b2e2a7eee668dc6dc4c21a5 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 13 Aug 2026 20:18:22 +0200 Subject: [PATCH 027/470] [const] move CONF_LABEL to components/const (#18354) Co-authored-by: Oliver Kleinecke Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/const/__init__.py | 1 + esphome/components/display_menu_base/__init__.py | 2 +- esphome/components/lvgl/widgets/label.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 2d02c7d179..3ba89d2838 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c..2120abe5f7 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -3,6 +3,7 @@ import re from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_LABEL from esphome.components.number import Number from esphome.components.select import Select from esphome.components.switch import Switch @@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") CONF_ROTARY = "rotary" CONF_JOYSTICK = "joystick" -CONF_LABEL = "label" CONF_MENU = "menu" CONF_BACK = "back" CONF_SELECT = "select" diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717..54c9819d2b 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -1,3 +1,4 @@ +from esphome.components.const import CONF_LABEL import esphome.config_validation as cv from esphome.const import CONF_TEXT @@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA from ..types import LvText from . import Widget, WidgetType -CONF_LABEL = "label" - class LabelType(WidgetType): def __init__(self): From db5173697a40c92e6f7a4dfc1d97c59580bc9271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:22:48 -0500 Subject: [PATCH 028/470] [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) --- .../components/ble_device_base/__init__.py | 20 ++- .../components/esp32_ble_tracker/__init__.py | 71 +++++++++- .../test_scan_parameter_validation.py | 7 +- .../esp32_ble_tracker/__init__.py | 0 .../test_scan_window_default.py | 122 ++++++++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/__init__.py create mode 100644 tests/component_tests/esp32_ble_tracker/test_scan_window_default.py diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) From 137351fa8d85f27130b8ccfcbdfa9a42555a6635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:27:01 -0500 Subject: [PATCH 029/470] [esp32_ble] Silence spurious warnings for local key GAP events (#18359) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: From 191686c5b3e106581ec58ab0ee15e6b8af4e9527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:30:53 -0500 Subject: [PATCH 030/470] [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) --- .../wifi/wifi_component_esp8266.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); From 7420d238673fb3a593b9314bef93f8e8e1cd4546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:06 -0500 Subject: [PATCH 031/470] [ota] Retry uploads that fail from network errors (#18332) --- esphome/espota2.py | 158 ++++++++++--- tests/unit_tests/test_espota2.py | 382 +++++++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 47 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 945c2458b3642964232503bf162bb9d1ab657d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:19 -0500 Subject: [PATCH 032/470] [core] Load component aliases from a generated registry (#18335) --- .github/workflows/ci.yml | 1 + esphome/component_aliases.py | 10 ++++++ esphome/loader.py | 61 +++++++++++++-------------------- script/build_alias_registry.py | 59 +++++++++++++++++++++++++++++++ tests/unit_tests/test_loader.py | 28 +++++++++++++++ 5 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 esphome/component_aliases.py create mode 100755 script/build_alias_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..b603e68ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # 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) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # 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: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # 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. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: 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). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # 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: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits From 37782f72069e10f0d6a32c79a46a4d58180cb240 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:54:37 -0500 Subject: [PATCH 033/470] Bump prek from 0.4.12 to 0.4.13 (#18362) 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 1832ffd433..95ee97437d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.12 # also change in .github/workflows/ci.yml when updating +prek==0.4.13 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 45a056e33774333778e0264222d4434aac434a36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:54:51 -0500 Subject: [PATCH 034/470] Bump platformdirs from 4.11.1 to 4.11.2 (#18363) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 683008400a..6bc8bdf74a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.1 # native esp-idf toolchain global cache dir +platformdirs==4.11.2 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From dd51624fbb909ccaa902e8d38480b91b782fb6ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 20:06:48 -0500 Subject: [PATCH 035/470] [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/infrared/infrared.cpp | 8 +- esphome/components/mqtt/__init__.py | 63 --- esphome/components/prometheus/__init__.py | 6 - .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/core/application.h | 8 +- esphome/core/entity_base.cpp | 52 +-- esphome/core/entity_base.h | 80 ++-- esphome/core/entity_helpers.py | 190 ++++---- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 14 +- esphome/core/preferences.cpp | 25 - esphome/core/preferences.h | 12 - esphome/helpers.py | 15 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 -- .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 -- tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 - .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ------- tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 ---------- tests/unit_tests/core/test_entity_helpers.py | 432 ++++++++++-------- .../object_id_conflict_mqtt.yaml | 22 - .../object_id_conflict_no_mqtt.yaml | 15 - .../test_preference_hash_stability.py | 34 +- 32 files changed, 489 insertions(+), 1132 deletions(-) delete mode 100644 esphome/core/preferences.cpp delete mode 100644 tests/integration/fixtures/preference_key_migration.yaml delete mode 100644 tests/integration/test_preference_key_migration.py delete mode 100644 tests/unit_tests/components/mqtt/__init__.py delete mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..73b4f3e5bd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml deleted file mode 100644 index a9b01fc2d2..0000000000 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ /dev/null @@ -1,35 +0,0 @@ -esphome: - name: host-pref-key-migration - -host: -api: -logger: - -switch: - - platform: template - id: test_switch_restore - name: Test Switch - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - -number: - - platform: template - id: test_number_restore - name: Test Number - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0 - max_value: 100 - step: 0.5 - -text: - - platform: template - id: test_text_restore - name: Test Text - mode: text - optimistic: true - restore_value: true - initial_value: fallback - min_length: 0 - max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..f835bee3bc 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) -def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: - """Write preference entries, replacing the file's contents. - - Returns the path that was written. - """ - payload = b"" - for key, data in entries.items(): - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - return write_host_prefs(device_name, {key: data}) + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + path = host_prefs_path(device_name) + path.parent.mkdir(parents=True, exist_ok=True) + payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py deleted file mode 100644 index e7f699bb12..0000000000 --- a/tests/integration/test_preference_key_migration.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration test for entity preference key migration. - -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. - -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data -""" - -from __future__ import annotations - -import socket -import struct - -from aioesphomeapi import ( - NumberInfo, - NumberState, - SwitchInfo, - SwitchState, - TextInfo, - TextState, -) -import pytest - -from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id - -from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client -from .host_prefs import clear_host_prefs, write_host_prefs -from .state_utils import InitialStateHelper, require_entity -from .types import CompileFunction, ConfigWriter - -DEVICE_NAME = "host-pref-key-migration" - -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") - -# template_text salts its key with the length limits and pattern hash; this must -# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, -# no pattern configured) -TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF - -# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes -TEXT_MAX_LENGTH = 20 - - -def text_pref_payload(value: str) -> bytes: - """Build the length-prefixed buffer TextSaver stores for a value.""" - data = value.encode("utf-8") - assert len(data) <= TEXT_MAX_LENGTH - return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) - - -@pytest.mark.asyncio -async def test_preference_key_migration( - yaml_config: str, - write_yaml_config: ConfigWriter, - compile_esphome: CompileFunction, - reserved_tcp_port: tuple[int, socket.socket], -) -> None: - """Test that preferences stored under the old key survive the upgrade.""" - port, port_socket = reserved_tcp_port - - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY - - # Write and compile once - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - - # Release the reserved port so the binary can bind to it - port_socket.close() - - async def boot_and_get_initial_states() -> tuple[ - SwitchState, NumberState, TextState - ]: - """Boot the binary and return the restored entity states.""" - async with ( - run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), - wait_and_connect_api_client(port=port) as client, - ): - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - - entities, _ = await client.list_entities_services() - switch_entity = require_entity( - entities, "test_switch", SwitchInfo, "Test Switch" - ) - number_entity = require_entity( - entities, "test_number", NumberInfo, "Test Number" - ) - text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") - - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(lambda s: None) - ) - await initial_state_helper.wait_for_initial_states() - - switch_state = initial_state_helper.initial_states[switch_entity.key] - number_state = initial_state_helper.initial_states[number_entity.key] - text_state = initial_state_helper.initial_states[text_entity.key] - assert isinstance(switch_state, SwitchState) - assert isinstance(number_state, NumberState) - assert isinstance(text_state, TextState) - return switch_state, number_state, text_state - - try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. - write_host_prefs( - DEVICE_NAME, - { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( From 990fc402fdf12fd71e0d327edc3045591617aa53 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:24:02 -0500 Subject: [PATCH 036/470] Bump bleak from 2.1.1 to 3.0.2 (#16246) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6bc8bdf74a..876b13793c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ pillow==12.3.0 resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 -bleak==2.1.1 +bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 From b05465145fb261eb3d142982fda2d4741fa49c92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 21:14:30 -0500 Subject: [PATCH 037/470] [core] Add preference key stability integration test (#18364) --- .../fixtures/preference_key_stability.yaml | 35 ++++ tests/integration/host_prefs.py | 24 ++- .../test_preference_key_stability.py | 168 ++++++++++++++++++ 3 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/preference_key_stability.yaml create mode 100644 tests/integration/test_preference_key_stability.py diff --git a/tests/integration/fixtures/preference_key_stability.yaml b/tests/integration/fixtures/preference_key_stability.yaml new file mode 100644 index 0000000000..a74bb2c7f7 --- /dev/null +++ b/tests/integration/fixtures/preference_key_stability.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-pref-key-stability + +host: +api: +logger: + +switch: + - platform: template + id: test_switch_restore + name: Test Switch + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + +number: + - platform: template + id: test_number_restore + name: Test Number + optimistic: true + restore_value: true + initial_value: 1.0 + min_value: 0 + max_value: 100 + step: 0.5 + +text: + - platform: template + id: test_text_restore + name: Test Text + mode: text + optimistic: true + restore_value: true + initial_value: fallback + min_length: 0 + max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index f835bee3bc..c7f21d8a01 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) +def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: + """Write preference entries, replacing the file's contents. + + Returns the path that was written. + """ + payload = b"" + for key, data in entries.items(): + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - path = host_prefs_path(device_name) - path.parent.mkdir(parents=True, exist_ok=True) - payload = struct.pack(" stores a length-prefixed buffer of max_length + 1 bytes +TEXT_MAX_LENGTH = 20 + + +def text_pref_payload(value: str) -> bytes: + """Build the length-prefixed buffer TextSaver stores for a value.""" + data = value.encode("utf-8") + assert len(data) <= TEXT_MAX_LENGTH + return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) + + +@pytest.mark.asyncio +async def test_preference_key_stability( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test that preferences stored by earlier firmware are restored.""" + port, port_socket = reserved_tcp_port + + assert SWITCH_KEY != SWITCH_BETA_KEY + assert NUMBER_KEY != NUMBER_BETA_KEY + assert TEXT_KEY != TEXT_BETA_KEY + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def boot_and_get_initial_states() -> tuple[ + SwitchState, NumberState, TextState + ]: + """Boot the binary and return the restored entity states.""" + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == DEVICE_NAME + + entities, _ = await client.list_entities_services() + switch_entity = require_entity( + entities, "test_switch", SwitchInfo, "Test Switch" + ) + number_entity = require_entity( + entities, "test_number", NumberInfo, "Test Number" + ) + text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda s: None) + ) + await initial_state_helper.wait_for_initial_states() + + switch_state = initial_state_helper.initial_states[switch_entity.key] + number_state = initial_state_helper.initial_states[number_entity.key] + text_state = initial_state_helper.initial_states[text_entity.key] + assert isinstance(switch_state, SwitchState) + assert isinstance(number_state, NumberState) + assert isinstance(text_state, TextState) + return switch_state, number_state, text_state + + try: + # --- Run 1: entries under the object_id-hash keys, exactly as any + # earlier firmware wrote them. The restored states prove the key + # scheme has not drifted. + write_host_prefs( + DEVICE_NAME, + { + SWITCH_KEY: b"\x01", # bool: switch was ON + NUMBER_KEY: struct.pack(" Date: Fri, 14 Aug 2026 00:22:22 -0500 Subject: [PATCH 038/470] [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) --- esphome/config_validation.py | 4 ++++ esphome/util.py | 14 ++++++++++++++ tests/unit_tests/test_config_validation.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From 617e2ec1e051f181ca7892966c2be34c46e2e806 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Fri, 14 Aug 2026 07:23:34 +0200 Subject: [PATCH 039/470] [core] fix PYTHONPATH leak (#18360) --- esphome/espidf/toolchain.py | 2 ++ esphome/framework_helpers.py | 2 ++ tests/unit_tests/test_espidf_toolchain.py | 15 +++++++++++++++ tests/unit_tests/test_framework_helpers.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + 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)) From d72bab79d7363c81d849078556f1de71953cf60c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 21:09:01 -0500 Subject: [PATCH 040/470] [web_server_base] Stop deleting the web server on captive portal teardown (#18324) --- .../web_server/ota/ota_web_server.cpp | 2 +- .../web_server_base/web_server_base.h | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } From 7c07fb48c5cbf1c5ca7c8ba04e033d2d0eb14568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:22:48 -0500 Subject: [PATCH 041/470] [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) --- .../components/ble_device_base/__init__.py | 20 ++- .../components/esp32_ble_tracker/__init__.py | 71 +++++++++- .../test_scan_parameter_validation.py | 7 +- .../esp32_ble_tracker/__init__.py | 0 .../test_scan_window_default.py | 122 ++++++++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/__init__.py create mode 100644 tests/component_tests/esp32_ble_tracker/test_scan_window_default.py diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) From 236ff33a09e4865c4ee88a0aae711d71955640f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:27:01 -0500 Subject: [PATCH 042/470] [esp32_ble] Silence spurious warnings for local key GAP events (#18359) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: From 1c3a67b5e815617abf419721525c182d302931aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:30:53 -0500 Subject: [PATCH 043/470] [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) --- .../wifi/wifi_component_esp8266.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); From 4f3153375a7acb307de0ce6deef708975fff58cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:06 -0500 Subject: [PATCH 044/470] [ota] Retry uploads that fail from network errors (#18332) --- esphome/espota2.py | 158 ++++++++++--- tests/unit_tests/test_espota2.py | 382 +++++++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 47 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 02c1810c3ad388b37025fd65307fd86dbf3e66a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:19 -0500 Subject: [PATCH 045/470] [core] Load component aliases from a generated registry (#18335) --- .github/workflows/ci.yml | 1 + esphome/component_aliases.py | 10 ++++++ esphome/loader.py | 61 +++++++++++++-------------------- script/build_alias_registry.py | 59 +++++++++++++++++++++++++++++++ tests/unit_tests/test_loader.py | 28 +++++++++++++++ 5 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 esphome/component_aliases.py create mode 100755 script/build_alias_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..b603e68ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # 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) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # 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: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # 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. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: 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). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # 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: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits From add18d4e351f582757781aa92f68eaa534ea8748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 20:06:48 -0500 Subject: [PATCH 046/470] [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/infrared/infrared.cpp | 8 +- esphome/components/mqtt/__init__.py | 63 --- esphome/components/prometheus/__init__.py | 6 - .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/core/application.h | 8 +- esphome/core/entity_base.cpp | 52 +-- esphome/core/entity_base.h | 80 ++-- esphome/core/entity_helpers.py | 190 ++++---- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 14 +- esphome/core/preferences.cpp | 25 - esphome/core/preferences.h | 12 - esphome/helpers.py | 15 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 -- .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 -- tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 - .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ------- tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 ---------- tests/unit_tests/core/test_entity_helpers.py | 432 ++++++++++-------- .../object_id_conflict_mqtt.yaml | 22 - .../object_id_conflict_no_mqtt.yaml | 15 - .../test_preference_hash_stability.py | 34 +- 32 files changed, 489 insertions(+), 1132 deletions(-) delete mode 100644 esphome/core/preferences.cpp delete mode 100644 tests/integration/fixtures/preference_key_migration.yaml delete mode 100644 tests/integration/test_preference_key_migration.py delete mode 100644 tests/unit_tests/components/mqtt/__init__.py delete mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..73b4f3e5bd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml deleted file mode 100644 index a9b01fc2d2..0000000000 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ /dev/null @@ -1,35 +0,0 @@ -esphome: - name: host-pref-key-migration - -host: -api: -logger: - -switch: - - platform: template - id: test_switch_restore - name: Test Switch - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - -number: - - platform: template - id: test_number_restore - name: Test Number - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0 - max_value: 100 - step: 0.5 - -text: - - platform: template - id: test_text_restore - name: Test Text - mode: text - optimistic: true - restore_value: true - initial_value: fallback - min_length: 0 - max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..f835bee3bc 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) -def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: - """Write preference entries, replacing the file's contents. - - Returns the path that was written. - """ - payload = b"" - for key, data in entries.items(): - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - return write_host_prefs(device_name, {key: data}) + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + path = host_prefs_path(device_name) + path.parent.mkdir(parents=True, exist_ok=True) + payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py deleted file mode 100644 index e7f699bb12..0000000000 --- a/tests/integration/test_preference_key_migration.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration test for entity preference key migration. - -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. - -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data -""" - -from __future__ import annotations - -import socket -import struct - -from aioesphomeapi import ( - NumberInfo, - NumberState, - SwitchInfo, - SwitchState, - TextInfo, - TextState, -) -import pytest - -from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id - -from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client -from .host_prefs import clear_host_prefs, write_host_prefs -from .state_utils import InitialStateHelper, require_entity -from .types import CompileFunction, ConfigWriter - -DEVICE_NAME = "host-pref-key-migration" - -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") - -# template_text salts its key with the length limits and pattern hash; this must -# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, -# no pattern configured) -TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF - -# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes -TEXT_MAX_LENGTH = 20 - - -def text_pref_payload(value: str) -> bytes: - """Build the length-prefixed buffer TextSaver stores for a value.""" - data = value.encode("utf-8") - assert len(data) <= TEXT_MAX_LENGTH - return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) - - -@pytest.mark.asyncio -async def test_preference_key_migration( - yaml_config: str, - write_yaml_config: ConfigWriter, - compile_esphome: CompileFunction, - reserved_tcp_port: tuple[int, socket.socket], -) -> None: - """Test that preferences stored under the old key survive the upgrade.""" - port, port_socket = reserved_tcp_port - - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY - - # Write and compile once - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - - # Release the reserved port so the binary can bind to it - port_socket.close() - - async def boot_and_get_initial_states() -> tuple[ - SwitchState, NumberState, TextState - ]: - """Boot the binary and return the restored entity states.""" - async with ( - run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), - wait_and_connect_api_client(port=port) as client, - ): - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - - entities, _ = await client.list_entities_services() - switch_entity = require_entity( - entities, "test_switch", SwitchInfo, "Test Switch" - ) - number_entity = require_entity( - entities, "test_number", NumberInfo, "Test Number" - ) - text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") - - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(lambda s: None) - ) - await initial_state_helper.wait_for_initial_states() - - switch_state = initial_state_helper.initial_states[switch_entity.key] - number_state = initial_state_helper.initial_states[number_entity.key] - text_state = initial_state_helper.initial_states[text_entity.key] - assert isinstance(switch_state, SwitchState) - assert isinstance(number_state, NumberState) - assert isinstance(text_state, TextState) - return switch_state, number_state, text_state - - try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. - write_host_prefs( - DEVICE_NAME, - { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( From b794b7b1d19d7491df12d417bca5ad19187ac767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 00:22:22 -0500 Subject: [PATCH 047/470] [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) --- esphome/config_validation.py | 4 ++++ esphome/util.py | 14 ++++++++++++++ tests/unit_tests/test_config_validation.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From c8de63276479cc80db40c03079b90b7c97a11f4f Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Fri, 14 Aug 2026 07:23:34 +0200 Subject: [PATCH 048/470] [core] fix PYTHONPATH leak (#18360) --- esphome/espidf/toolchain.py | 2 ++ esphome/framework_helpers.py | 2 ++ tests/unit_tests/test_espidf_toolchain.py | 15 +++++++++++++++ tests/unit_tests/test_framework_helpers.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + 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)) From 4db47de556375f0554001d03c6c80fdea005db97 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:36:56 +1200 Subject: [PATCH 049/470] Bump version to 2026.8.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a8c77f4bb8..d9421273af 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.8.0b2 +PROJECT_NUMBER = 2026.8.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 b6770d0001..1a8be98c03 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b2" +__version__ = "2026.8.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From be66e8b99c3aaee6b2ed8b3ab75434ecdfdde612 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:33:47 -0700 Subject: [PATCH 050/470] [ci] Disable CodSpeed benchmarks job outside esphome/esphome (#18372) --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603e68ad7..026c2ba27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -445,8 +445,12 @@ jobs: - common - determine-jobs if: >- - (github.event_name == 'push' && github.ref_name == 'dev') || - (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + github.repository == 'esphome/esphome' && ( + (github.event_name == 'push' && github.ref_name == 'dev') || + (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + ) + # CodSpeed benchmarks require a CodSpeed account linked to the repository to run + # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself. steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From e5224e22ae1fd07a284794690db68544f76f1b0c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:18:22 -0700 Subject: [PATCH 051/470] [ci] Compare merge-branch base ref against the default branch (#18385) --- .github/scripts/auto-label-pr/detectors.js | 3 ++- .../auto-label-pr/tests/detectors.test.js | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index bb85ccd681..1d76c18be8 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -70,6 +70,7 @@ async function isStackedPr(github, context) { async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; + const defaultBranch = context.payload.repository.default_branch; if (baseRef === 'release') { labels.add('merging-to-release'); @@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) { } else if (await isStackedPr(github, context)) { // GitHub manages the merge order for a stack, so these are not blocked. labels.add('stacked-pr'); - } else if (baseRef !== 'dev') { + } else if (baseRef !== defaultBranch) { // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index f30ceff8c1..be239e2f1b 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; // Builds a fresh context for detectMergeBranch tests instead of mutating the // shared CONTEXT fixture above (which other describe blocks rely on). -function makeMergeContext(baseRef, { stack } = {}) { +function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) { const pull_request = { number: 1, base: { ref: baseRef } }; if (stack !== undefined) { pull_request.stack = stack; } return { repo: { owner: 'esphome', repo: 'esphome' }, - payload: { pull_request } + payload: { pull_request, repository: { default_branch: defaultBranch } } }; } @@ -136,6 +136,21 @@ describe('detectMergeBranch', () => { assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); assert.equal(state.calls, 1); }); + + it('base ref matches default branch adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('other', { defaultBranch: 'other' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('base ref dev when the default branch is main adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev', { defaultBranch: 'main' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + }); // --------------------------------------------------------------------------- From b178f74e5d6b229293b28bfd2cc78ffb79f5be77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 18:45:23 -0700 Subject: [PATCH 052/470] [core] Save the validated config cache on the first upload or logs run (#18367) --- esphome/__main__.py | 28 +- esphome/compiled_config.py | 77 ++++- esphome/components/esp32/__init__.py | 3 + esphome/components/esp8266/__init__.py | 3 + esphome/components/libretiny/__init__.py | 3 + esphome/components/nrf52/__init__.py | 3 + esphome/components/rp2/__init__.py | 3 + esphome/storage_json.py | 56 +++- .../fixtures/lazy_imports/_storage.py | 11 +- tests/unit_tests/test_compiled_config.py | 299 ++++++++++++++++-- tests/unit_tests/test_download_types.py | 52 +++ tests/unit_tests/test_storage_json.py | 99 ++++++ 12 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 tests/unit_tests/test_download_types.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 1262a4525e..c1e05d2ea7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2732,7 +2732,8 @@ def run_esphome(argv): conf_path.name, ) - if config is None: + cache_missed = config is None + if cache_missed: from esphome.config import read_config config = read_config( @@ -2741,26 +2742,25 @@ def run_esphome(argv): # Snapshot only needed by `esphome config --no-defaults`. snapshot_user_config=getattr(args, "no_defaults", False), ) - # Refresh the cache so the next upload/logs hits the fast path - # instead of re-running read_config. Skip when the storage - # sidecar is absent (no compile has run): the cache would - # never be loaded back, so writing secrets to disk is wasted. - if cache_eligible and config is not None: - from esphome.compiled_config import save_compiled_config - from esphome.storage_json import ext_storage_path - - if ext_storage_path(conf_path.name).exists(): - save_compiled_config(config) - if config is None: - return 2 + if config is None: + return 2 CORE.config = config # Fallback for platforms whose validators didn't set the toolchain # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. + # other platforms only support PlatformIO today. Must run before the + # cache refresh below so its sidecar records the same toolchain a + # compile would. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. + if cache_eligible and cache_missed: + from esphome.compiled_config import save_compiled_config_and_sidecar + + save_compiled_config_and_sidecar(config) + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 303af99e66..be03eea965 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -18,9 +18,9 @@ from pathlib import Path from typing import Any from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.helpers import write_file -from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.storage_json import StorageJSON, ext_storage_path, storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None: # non-basic dict key), so every upload/logs pays the slow path. _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) + # Likely persistent (permissions, full disk): every upload/logs + # pays the slow path until it clears, so surface it. + _LOGGER.warning("Skipping compiled config cache write: %s", err) + + +def save_compiled_config_and_sidecar(config: ConfigType) -> None: + """Refresh the cache from the upload/logs fallback (CORE.config must be set). + + The cache is only written when a complete sidecar is on disk: + load_compiled_config can't use it otherwise, and it holds resolved + secrets. + """ + if _refresh_sidecar(): + save_compiled_config(config) + + +def _refresh_sidecar() -> bool: + """Ensure a complete sidecar is on disk; True when one is. + + Writes one (without claiming a build) when missing or wizard-only. + Failures are non-fatal; the next upload/logs pays the slow path again. + """ + try: + path = storage_path() + try: + old = StorageJSON.load_strict(path) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Present but unreadable: it may hold a real build's metadata, + # and a fresh rewrite would also stop the next compile from + # cleaning a possibly incoherent build tree. + _LOGGER.warning( + "Not caching: storage sidecar %s is unreadable (%s)", path, err + ) + return False + if old is not None and old.can_apply_to_core(): + # Compile-written; nothing to refresh. + return True + if CORE.build_path is not None and CORE.build_path.exists(): + # An unvalidated build tree: its absent or mismatched sidecar + # is what makes the next compile wipe it, so don't vouch for + # a build this run never saw. + _LOGGER.warning( + "Not caching: build tree %s has no matching sidecar; " + "'esphome compile' will settle it", + CORE.build_path, + ) + return False + new = StorageJSON.from_esphome_core(CORE, old, claim_build=False) + if not new.can_apply_to_core(): + _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete") + return False + new.save(path) + return True + except (OSError, EsphomeError) as err: + # write_file wraps OSError into EsphomeError. Persistent + # (unwritable storage dir), so surface that every upload/logs + # pays the slow path. + _LOGGER.warning("Could not refresh the storage sidecar: %s", err) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + # A structural bug; keep the traceback so it isn't mistaken + # for the I/O failure above. + _LOGGER.warning( + "Unexpected error refreshing the storage sidecar", exc_info=True + ) + return False def load_compiled_config(conf_path: Path) -> ConfigType | None: @@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - return None - # apply_to_core assumes a real compile wrote the sidecar; wizard-only - # sidecars leave both of these unset and can't drive upload/logs. - if not storage.core_platform and not storage.target_platform: + if storage is None or not storage.can_apply_to_core(): + _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete") return None storage.apply_to_core() return config diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..7263571d69 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -570,6 +570,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Factory format (Previously Modern)", diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1f7159919d..2161a902cb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -113,6 +113,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Standard format", diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c51af373b3..c56cc48055 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [ { "title": "UF2 package (recommended)", diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 386fed5412..2d25558254 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -473,6 +473,9 @@ def copy_files() -> None: def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Get the download types for the firmware.""" + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 87e78003ed..60fcd4f8b0 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -156,6 +156,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "UF2 factory format", diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a90a36b848..9219914529 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,8 +71,11 @@ def archive_storage_path() -> Path: def _to_path_if_not_none(value: str | None) -> Path | None: - """Convert a string to Path if it's not None.""" - return Path(value) if value is not None else None + """Convert a string to Path; None and the legacy "None" both map to None. + + Sidecars written before as_dict skipped unset paths hold str(None). + """ + return Path(value) if value is not None and value != "None" else None def _parse_framework_version(framework_version: str) -> Version: @@ -170,8 +173,10 @@ class StorageJSON: "address": self.address, "web_port": self.web_port, "esp_platform": self.target_platform, - "build_path": str(self.build_path), - "firmware_bin_path": str(self.firmware_bin_path), + "build_path": str(self.build_path) if self.build_path else None, + "firmware_bin_path": ( + str(self.firmware_bin_path) if self.firmware_bin_path else None + ), "loaded_integrations": sorted(self.loaded_integrations), "loaded_platforms": sorted(self.loaded_platforms), "no_mdns": self.no_mdns, @@ -189,7 +194,18 @@ class StorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: + def from_esphome_core( + esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True + ) -> StorageJSON: + """Build a sidecar from post-validation CORE state. + + claim_build=False (the upload/logs fallback, which runs no build) + carries the build-artifact fields (esphome_version, + firmware_bin_path) from *old* instead of asserting this run built + firmware. Validation-derived fields (platform, framework_version, + toolchain, build_path) always stamp; storage_should_clean compares + them against the next compile. + """ hardware = esph.target_platform.upper() framework_version: str | None = None if esph.is_esp32: @@ -204,13 +220,21 @@ class StorageJSON: name=esph.name, friendly_name=esph.friendly_name, comment=esph.comment, - esphome_version=const.__version__, + esphome_version=( + const.__version__ + if claim_build + else (old.esphome_version if old else None) + ), src_version=1, address=esph.address, web_port=esph.web_port, target_platform=hardware, build_path=esph.build_path, - firmware_bin_path=esph.firmware_bin, + firmware_bin_path=( + esph.firmware_bin + if claim_build + else (old.firmware_bin_path if old else None) + ), loaded_integrations=esph.loaded_integrations, loaded_platforms=esph.loaded_platforms, no_mdns=( @@ -302,11 +326,27 @@ class StorageJSON: except Exception: # noqa: BLE001 # pylint: disable=broad-except return None + @staticmethod + def load_strict(path: Path) -> StorageJSON | None: + """Like load, but None only means missing; an unreadable file raises.""" + if not path.is_file(): + return None + return StorageJSON._load_impl(path) + + def can_apply_to_core(self) -> bool: + """True when the sidecar carries everything apply_to_core hands CORE. + + Wizard-written sidecars leave build_path unset (older wizards also + the platform fields) and can't drive upload/logs. + """ + return bool((self.core_platform or self.target_platform) and self.build_path) + def apply_to_core(self) -> None: """Populate CORE with the metadata upload/logs read. Inverse of :meth:`from_esphome_core`. Keep paired -- a new - attribute upload/logs needs has to be captured there too. + attribute upload/logs needs has to be captured there too and + reflected in :meth:`can_apply_to_core`. Validator-only fields (loaded_integrations/platforms, friendly_name) are skipped; the fast path doesn't run validation and CORE.__init__ defaults them. diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py index 969528304b..94acd2e93a 100644 --- a/tests/unit_tests/fixtures/lazy_imports/_storage.py +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -1,10 +1,15 @@ """Shared storage-sidecar factory for the lazy-import fixture scripts.""" +from pathlib import Path + from esphome.storage_json import StorageJSON def make_storage() -> StorageJSON: - """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts. + + build_path must be set: the fast path rejects sidecars without one. + """ return StorageJSON( storage_version=1, name="test", @@ -15,8 +20,8 @@ def make_storage() -> StorageJSON: address="1.2.3.4", web_port=None, target_platform="ESP32S3", - build_path=None, - firmware_bin_path=None, + build_path=Path("/build/test"), + firmware_bin_path=Path("/build/test/firmware.bin"), loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b3c2170c3f..77690a6897 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from ipaddress import IPv4Address, IPv4Network import json import os @@ -19,6 +20,7 @@ from esphome.compiled_config import ( compiled_config_path, load_compiled_config, save_compiled_config, + save_compiled_config_and_sidecar, ) from esphome.const import ( CONF_API, @@ -31,7 +33,16 @@ from esphome.const import ( KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.core import ( + CORE, + ID, + EsphomeError, + HexInt, + Lambda, + MACAddress, + TimePeriodMilliseconds, +) +from esphome.storage_json import StorageJSON from esphome.util import OrderedDict _VALIDATED_CONFIG = { @@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str: def _write_storage( storage_path: Path, *, - esp_platform: str = "ESP32", + esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", + build_path: str | None = "/build/lite_test", ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -69,7 +81,7 @@ def _write_storage( "address": "192.168.1.42", "web_port": None, "esp_platform": esp_platform, - "build_path": "/build/lite_test", + "build_path": build_path, "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], @@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() -def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( - tmp_path: Path, -) -> None: - """Without a StorageJSON sidecar (no compile has run), the fallback - skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) config would be inert and - leak secrets to disk for nothing.""" +def _storage_fixture(tmp_path: Path) -> StorageJSON: + """A loaded StorageJSON instance matching _write_storage's contents.""" + fixture = tmp_path / "fixture_storage.json" + _write_storage(fixture) + return StorageJSON.load(fixture) + + +def _bare_yaml(tmp_path: Path) -> Path: + """A minimal YAML with CORE.config_path pointed at it.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path + return yaml_path + +@contextmanager +def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any: + """Patch the fallback path's collaborators for a run_esphome call. + + Without kwargs, from_esphome_core stays real (yielded mock is None). + """ with ( patch( "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, - ), - patch("esphome.compiled_config.save_compiled_config") as mock_save, + ) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {command: lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + if not from_core_kwargs: + yield mock_read, None + return + with patch.object( + StorageJSON, "from_esphome_core", **from_core_kwargs + ) as mock_from_core: + yield mock_read, mock_from_core + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar( + tmp_path: Path, command: str +) -> None: + """A never-compiled config caches on its first upload/logs run: the + fallback writes the StorageJSON sidecar itself (load_compiled_config + needs it), so the second run hits the fast path.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as ( + mock_read, + mock_from_core, + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_from_core.assert_called_once() + assert (storage_dir / "lite_test.yaml.validated.json").exists() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None + # No compile happened, so the sidecar must not claim one. + assert mock_from_core.call_args.kwargs == {"claim_build": False} + + # The second run loads the cache instead of re-validating. + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_read.assert_called_once() + + +# as_dict serialized unset paths as str(None) until 2026.9; files +# written by those wizards are still on disk. +_WIZARD_SIDECAR_CASES = pytest.mark.parametrize( + "wizard_kwargs", + [ + {"esp_platform": None, "core_platform": None, "build_path": None}, + {"build_path": None}, + {"build_path": "None"}, + ], + ids=["legacy_wizard", "modern_wizard", "none_string_wizard"], +) + + +def _prime_core(tmp_path: Path) -> None: + """Set the post-validation CORE state from_esphome_core reads.""" + CORE.name = "lite_test" + CORE.build_path = tmp_path / "build" / "lite_test" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + +@_WIZARD_SIDECAR_CASES +def test_run_esphome_fallback_completes_wizard_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar can't drive the fast path (no build_path; + older wizards also no platform fields); the fallback rewrites it from + CORE so the cache loads on the next run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_called_once() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None and storage.core_platform == "esp32" + # What the wizard recorded about a build (nothing, or a real one) + # carries through instead of being stamped with this run's values. + assert storage.esphome_version == "2026.1.0" + assert load_compiled_config(yaml_path) is not None + + +def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails( + tmp_path: Path, +) -> None: + """A failed sidecar write is non-fatal and skips the cache save too: + without the sidecar the cache could never be loaded back, so writing + it would only leave resolved secrets on disk.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(side_effect=RuntimeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 mock_save.assert_not_called() + assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists() + + +def test_run_esphome_fallback_write_failure_takes_io_branch( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """StorageJSON.save raises EsphomeError (write_file wraps OSError into + it), which must land in the plain I/O warning, not the traceback + branch for structural bugs.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(return_value=_storage_fixture(tmp_path)), + patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + caplog.at_level("WARNING", logger="esphome.compiled_config"), + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_save.assert_not_called() + assert "Could not refresh the storage sidecar" in caplog.text + assert "Unexpected error" not in caplog.text + + +def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None: + """A present-but-corrupt sidecar is not overwritten: it may hold a real + build's metadata, and replacing it would suppress the next compile's + clean of a possibly incoherent build tree. The cache save is skipped.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + sidecar = storage_dir / "lite_test.yaml.json" + sidecar.parent.mkdir(parents=True, exist_ok=True) + sidecar.write_text("{truncated", encoding="utf-8") + + with _fallback_run(return_value=None) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert sidecar.read_text(encoding="utf-8") == "{truncated" + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete( + tmp_path: Path, +) -> None: + """If the rebuilt sidecar would still be incomplete, nothing is written: + the cache could never be loaded back, so saving it would only rewrite + resolved secrets on every run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + incomplete = tmp_path / "incomplete_storage.json" + _write_storage(incomplete, build_path=None) + + with _fallback_run(return_value=StorageJSON.load(incomplete)): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + assert not (storage_dir / "lite_test.yaml.json").exists() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_sidecar_records_platformio_toolchain( + tmp_path: Path, +) -> None: + """The toolchain fallback runs before the sidecar write, so platforms + whose validators leave CORE.toolchain unset record the same + "platformio" a compile writes, not null.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + assert CORE.toolchain is None + + with _fallback_run(): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.toolchain == "platformio" + + +@pytest.mark.parametrize("existing_sidecar", [None, "wizard"]) +def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists( + tmp_path: Path, existing_sidecar: str | None +) -> None: + """An existing build tree with a missing or wizard-only sidecar keeps + it that way: the mismatch is what makes the next compile wipe the + unknown tree, so the fallback writes nothing and skips the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.build_path.mkdir(parents=True) + storage_dir = tmp_path / ".esphome" / "storage" + if existing_sidecar == "wizard": + _write_storage(storage_dir / "lite_test.yaml.json", build_path=None) + wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + if existing_sidecar == "wizard": + sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + assert sidecar_body == wizard_body + else: + assert not (storage_dir / "lite_test.yaml.json").exists() + + +def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None: + """Drive the real from_esphome_core on the fallback path: the + post-validation CORE state yields a complete, loadable sidecar.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + + save_compiled_config_and_sidecar(CORE.config) + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.core_platform == "esp8266" + assert storage.build_path is not None + # No compile happened, so the sidecar must not claim one. + assert storage.esphome_version is None + assert storage.firmware_bin_path is None + assert load_compiled_config(yaml_path) is not None @pytest.mark.parametrize("command", ["upload", "logs"]) @@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, + patch.object(StorageJSON, "from_esphome_core") as mock_from_core, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( assert run_esphome(["esphome", command, str(yaml_path)]) == 0 mock_save.assert_called_once_with(fresh_config) + # The compile-written sidecar is complete; the fallback leaves it alone. + mock_from_core.assert_not_called() # mtime is now newer than the source YAML, so a follow-up call hits # the fast path instead of repeating read_config. assert cache.stat().st_mtime >= yaml_path.stat().st_mtime @@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: assert config["table"] == {"1": "a", "2": "b"} -def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: - """A wizard-only sidecar (no compile -- no core_platform / target_platform) - can't drive upload/logs, so the fast path falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - CORE.config_path = yaml_path - +@_WIZARD_SIDECAR_CASES +def test_load_compiled_config_rejects_wizard_only_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar (no build_path; older wizards also no + platform fields) can't drive upload/logs, so the fast path falls back.""" + yaml_path = _bare_yaml(tmp_path) storage_dir = tmp_path / ".esphome" / "storage" - storage_dir.mkdir(parents=True, exist_ok=True) - # StorageJSON with both core_platform and target_platform unset. - (storage_dir / "lite_test.yaml.json").write_text( - '{"storage_version": 1, "name": "lite_test", "friendly_name": null, ' - '"comment": null, "esphome_version": null, "src_version": 1, ' - '"address": null, "web_port": null, "esp_platform": null, ' - '"build_path": null, "firmware_bin_path": null, ' - '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' - '"framework": null, "core_platform": null}' - ) + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py new file mode 100644 index 0000000000..2ccf53f7e3 --- /dev/null +++ b/tests/unit_tests/test_download_types.py @@ -0,0 +1,52 @@ +"""Platform get_download_types contract for never-built configs. + +Wizard-written and upload/logs-fallback sidecars record no +firmware_bin_path; the download panel must get an empty list for them, +not entries pointing at files that were never built. +""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON + +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] + + +def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: + return import_module(f"esphome.components.{platform}").get_download_types(storage) + + +def _wizard_storage() -> StorageJSON: + return StorageJSON.from_wizard( + name="test_device", + friendly_name="Test Device", + address="test_device.local", + platform="ESP32", + ) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_no_firmware_path_yields_no_downloads(platform: str) -> None: + """No recorded firmware path means nothing was built; no downloads.""" + assert _download_types(platform, _wizard_storage()) == [] + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None: + """With a firmware path recorded, every platform offers entries in + the documented title/description/file/download shape.""" + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + + types = _download_types(platform, storage) + + assert types + assert all( + {"title", "description", "file", "download"} <= entry.keys() for entry in types + ) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 01683507c1..857795d02f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None: legacy = storage_json.StorageJSON.load(legacy_path) assert legacy is not None assert legacy.area is None + + +def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None: + """claim_build=False carries the build artifact fields from the old + sidecar while validation-derived fields still stamp from CORE.""" + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = None + mock_core.address = "my_device.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.is_nrf52 = False + mock_core.build_path = "/build/my_device" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "arduino" + mock_core.toolchain = Toolchain.PLATFORMIO + mock_core.area = None + + old = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP8266", + ) + old.esphome_version = "2025.1.0" + old.firmware_bin_path = Path("/old/firmware.bin") + + result = storage_json.StorageJSON.from_esphome_core( + mock_core, old, claim_build=False + ) + + # Build artifact fields carry from the old sidecar, not this run. + assert result.esphome_version == "2025.1.0" + assert result.firmware_bin_path == Path("/old/firmware.bin") + # Validation-derived fields stamp from CORE. + assert result.build_path == "/build/my_device" + assert result.toolchain == "platformio" + assert result.core_platform == "esp8266" + + # With no old sidecar, no build is claimed at all. + bare = storage_json.StorageJSON.from_esphome_core( + mock_core, None, claim_build=False + ) + assert bare.esphome_version is None + assert bare.firmware_bin_path is None + + +def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None: + """load_strict returns None only for a missing file; corrupt raises.""" + assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None + + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{truncated") + with pytest.raises(ValueError): + storage_json.StorageJSON.load_strict(corrupt) + + +def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None: + """Unset build/firmware paths serialize as JSON null, not str(None).""" + storage = storage_json.StorageJSON.from_wizard( + name="wiz", + friendly_name="Wiz", + address="wiz.local", + platform="ESP32", + ) + + result = storage.as_dict() + + assert result["build_path"] is None + assert result["firmware_bin_path"] is None + + +def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None: + """Sidecars written before as_dict emitted null hold str(None); those + must load as unset, not as Path("None").""" + file_path = tmp_path / "legacy_none.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "wiz", + "friendly_name": "Wiz", + "esp_platform": "ESP32", + "core_platform": "esp32", + "build_path": "None", + "firmware_bin_path": "None", + } + ) + ) + + result = storage_json.StorageJSON.load(file_path) + + assert result is not None + assert result.build_path is None + assert result.firmware_bin_path is None From 039b897e7b83267ffe2cee749138b29cf1a5b2cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 18:45:36 -0700 Subject: [PATCH 053/470] [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) --- esphome/components/ethernet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8bdd536ffb..f3c77baaae 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -355,7 +355,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.9.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, From 7cceddb8a34b891681b150a8e45af49d80898228 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:27 +0000 Subject: [PATCH 054/470] Bump bundled esphome-device-builder to 1.10.0 (#18389) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ From 6ed676fe32a35a82f9857fdb2319c18102d1f8cd Mon Sep 17 00:00:00 2001 From: Joppy Furr Date: Sat, 15 Aug 2026 18:14:53 +1200 Subject: [PATCH 055/470] [lvgl] Restore long_press_repeat_time functionality (#18393) --- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index b66a904437..acd5a9bdef 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); lv_indev_set_disp(this->drv_, parent->get_disp()); lv_indev_set_long_press_time(this->drv_, long_press_time); - // long press repeat time TBD + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); lv_indev_set_user_data(this->drv_, this); lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { auto *l = static_cast(lv_indev_get_user_data(d)); From 5a000cf5e43acbbdd3f9a82e84302094cd9b2e0f Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Sat, 15 Aug 2026 11:16:04 -0700 Subject: [PATCH 056/470] [rotary_encoder] account for min and max value when resetting (#18197) --- esphome/components/rotary_encoder/rotary_encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { From 1add72689222010acbd521d2437260183fb3c731 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:52 -0700 Subject: [PATCH 057/470] Bump bundled esphome-device-builder to 1.11.0 (#18403) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a62eb59a58..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ From de3e657d8bcae1ec1c9298ff869390d77d2e25d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 08:42:47 -0700 Subject: [PATCH 058/470] [platformio] Skip ccache when the binary on PATH fails to run (#18407) --- esphome/platformio/toolchain.py | 32 ++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 48 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} From 646501b0eff760267fd12de74c5fb5d283779eaa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:36:51 -0400 Subject: [PATCH 059/470] [sensor] Pass NaN through the delta filter again (#18400) --- esphome/components/sensor/filter.cpp | 10 +++-- .../fixtures/sensor_filters_delta.yaml | 36 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 38 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index 9d0114e0c4..af8f314f49 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest @@ -25,6 +26,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": [], "filter_zero_delta": [], "filter_percentage": [], + "filter_nan": [], } filter_min_done = loop.create_future() @@ -32,16 +34,23 @@ async def test_sensor_filters_delta( filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() + filter_nan_done = loop.create_future() def on_state(state: EntityState) -> None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" From 2bc4681fd6d54d5959b93e6e5873b35ece42196d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:33:25 +0200 Subject: [PATCH 060/470] [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) --- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..62fd597845 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From c664f5fc951a8ae55eef64f14a382cdfc9e0b3dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 11:06:22 -0700 Subject: [PATCH 061/470] [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) --- esphome/components/bk72xx_ble/__init__.py | 44 ++++++++++++++++--- esphome/components/bk72xx_ble/bdk_scan.cpp | 4 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 22 ++++++---- tests/component_tests/bk72xx_ble/__init__.py | 0 .../bk72xx_ble/config/test_bk7231n.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231q.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231t.yaml | 7 +++ .../bk72xx_ble/config/test_bk7252.yaml | 7 +++ .../bk72xx_ble/test_family_gate.py | 40 +++++++++++++++++ .../config/bk72xx_controller_only.yaml | 2 +- .../config/bk72xx_tracker.yaml | 2 +- 11 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/__init__.py create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7252.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_family_gate.py diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/tests/component_tests/bk72xx_ble/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: From 32c76ae8289326cb2f17d9712db190fc2d599028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:43:27 -0700 Subject: [PATCH 062/470] [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) --- esphome/core/wake/wake_esp8266.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { // Set the wake-requested flag BEFORE esp_schedule so the consumer is // guaranteed to see it on its next gate check. wake_request_set(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } From 801a1817b58909e5bc243b493c53e3e2265ada07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:00 -0700 Subject: [PATCH 063/470] [esp32] Split crash handler addr2line hint per core (#18418) --- esphome/components/esp32/crash_handler.cpp | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + 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); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 From 3f01f9895f0c98179d8301dbed46aa02801d7f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:49 -0700 Subject: [PATCH 064/470] [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) --- esphome/components/esp32_hosted/__init__.py | 34 ++++++++++++------ .../component_tests/esp32_hosted/__init__.py | 0 .../component_tests/esp32_hosted/test_init.py | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/esp32_hosted/__init__.py create mode 100644 tests/component_tests/esp32_hosted/test_init.py diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index c6a714aace..d3432fb461 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -16,8 +16,10 @@ from esphome.const import ( CONF_VARIANT, ) from esphome.cpp_generator import add_define +from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] +DEPENDENCIES = ["esp32"] # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] @@ -124,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + # The esp_hosted releases compatible with older ESP-IDF versions crash at + # boot with a heap double free in the SDIO RX path (fixed in esp_hosted + # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. + if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0): + raise cv.Invalid( + f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. " + "Remove the framework version from your configuration to use the " + "recommended version, or pin a version at or above 5.3." + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _configure_sdio(config): slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( @@ -251,18 +269,14 @@ async def to_code(config): if config[CONF_USE_PSRAM]: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) - # Library versions + # Library versions; this component set requires ESP-IDF 5.3 or newer, + # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" - if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") - 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") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/tests/component_tests/esp32_hosted/__init__.py b/tests/component_tests/esp32_hosted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py new file mode 100644 index 0000000000..cec81e4e83 --- /dev/null +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -0,0 +1,35 @@ +"""Tests for the esp32_hosted ESP-IDF version gate.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_hosted import _final_validate +from esphome.const import PlatformFramework + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"]) +def test_final_validate_accepts_supported_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF 5.3 and newer passes validation unchanged.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + assert _final_validate({}) == {} + + +@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) +def test_final_validate_rejects_old_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF older than 5.3 is rejected with a clear error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"): + _final_validate({}) From 9161f74bb1e58b29f76f92bd5c298adbcbdf728b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:27 +0000 Subject: [PATCH 065/470] Bump bundled esphome-device-builder to 1.10.0 (#18389) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ From 46a5665a66873f990398a477dab767c8620e66a1 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Sat, 15 Aug 2026 11:16:04 -0700 Subject: [PATCH 066/470] [rotary_encoder] account for min and max value when resetting (#18197) --- esphome/components/rotary_encoder/rotary_encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { From dda4566b9e32fd2fab3faa5b7a7335c0bda2fda3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:52 -0700 Subject: [PATCH 067/470] Bump bundled esphome-device-builder to 1.11.0 (#18403) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a62eb59a58..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ From ce09504c923a171935d4cb80e598aeaf1cdea1fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 08:42:47 -0700 Subject: [PATCH 068/470] [platformio] Skip ccache when the binary on PATH fails to run (#18407) --- esphome/platformio/toolchain.py | 32 ++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 48 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} From bca72e9b6d7d6a4bebff6da0a946e952aef081e5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:36:51 -0400 Subject: [PATCH 069/470] [sensor] Pass NaN through the delta filter again (#18400) --- esphome/components/sensor/filter.cpp | 10 +++-- .../fixtures/sensor_filters_delta.yaml | 36 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 38 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index 9d0114e0c4..af8f314f49 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest @@ -25,6 +26,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": [], "filter_zero_delta": [], "filter_percentage": [], + "filter_nan": [], } filter_min_done = loop.create_future() @@ -32,16 +34,23 @@ async def test_sensor_filters_delta( filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() + filter_nan_done = loop.create_future() def on_state(state: EntityState) -> None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" From 594c12b3d961a20576b2425e75d4d05f18fc1993 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:33:25 +0200 Subject: [PATCH 070/470] [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) --- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..62fd597845 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From 0bc2d7137078ccb28aa3a8fc8ddbb4ae100a3c52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 11:06:22 -0700 Subject: [PATCH 071/470] [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) --- esphome/components/bk72xx_ble/__init__.py | 44 ++++++++++++++++--- esphome/components/bk72xx_ble/bdk_scan.cpp | 4 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 22 ++++++---- tests/component_tests/bk72xx_ble/__init__.py | 0 .../bk72xx_ble/config/test_bk7231n.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231q.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231t.yaml | 7 +++ .../bk72xx_ble/config/test_bk7252.yaml | 7 +++ .../bk72xx_ble/test_family_gate.py | 40 +++++++++++++++++ .../config/bk72xx_controller_only.yaml | 2 +- .../config/bk72xx_tracker.yaml | 2 +- 11 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/__init__.py create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7252.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_family_gate.py diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/tests/component_tests/bk72xx_ble/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: From f42fe9af297c8a19c63fdaa2ae06aac43748186b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:43:27 -0700 Subject: [PATCH 072/470] [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) --- esphome/core/wake/wake_esp8266.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { // Set the wake-requested flag BEFORE esp_schedule so the consumer is // guaranteed to see it on its next gate check. wake_request_set(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } From bb7d4c3630bf085c45c6991c8d5964baeb2da832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:00 -0700 Subject: [PATCH 073/470] [esp32] Split crash handler addr2line hint per core (#18418) --- esphome/components/esp32/crash_handler.cpp | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + 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); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 From 1ec21a22450393cfe777fc6f3923adaa085ff890 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:22:41 +1200 Subject: [PATCH 074/470] Bump version to 2026.8.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d9421273af..2df6d3ded0 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.8.0b3 +PROJECT_NUMBER = 2026.8.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 1a8be98c03..73155e06ee 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b3" +__version__ = "2026.8.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 58d549ed4c53ddc72408a8e18f81c12a3648d30a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:56:37 -0700 Subject: [PATCH 075/470] [api] Move NoiseProtocolId off the connection object (#18420) --- .../components/api/api_frame_helper_noise.cpp | 25 +++++++++++-------- .../components/api/api_frame_helper_noise.h | 3 --- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 225bac51a6..09e3ca2b9e 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { */ APIError APINoiseFrameHelper::init_handshake_() { int err; - memset(&nid_, 0, sizeof(nid_)); - // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; - // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto)); - nid_.pattern_id = NOISE_PATTERN_NN; - nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY; - nid_.dh_id = NOISE_DH_CURVE25519; - nid_.prefix_id = NOISE_PREFIX_STANDARD; - nid_.hybrid_id = NOISE_DH_NONE; - nid_.hash_id = NOISE_HASH_SHA256; - nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; + // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: + // noise_handshakestate_new_by_id copies it, so a member would waste + // 104 bytes per connection, and a static const would sit in RAM on + // ESP8266 (.rodata is DRAM there). + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; - err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); + err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index b0ba9fd01c..46bd366672 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Buffer for noise handshake prologue (released after handshake) APIBuffer prologue_; - // NoiseProtocolId (size depends on implementation) - NoiseProtocolId nid_; - // Group small types together // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) From cf764740cf8c186907edb09354df0e4d95750f1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:56:58 -0700 Subject: [PATCH 076/470] [api] Create the camera image reader lazily (#18421) --- esphome/components/api/api_connection.cpp | 26 +++--- esphome/components/camera/camera.h | 3 +- tests/integration/fixtures/camera_mock.yaml | 19 +++++ .../mock_camera/__init__.py | 28 +++++++ .../mock_camera/mock_camera.cpp | 30 +++++++ .../mock_camera/mock_camera.h | 80 +++++++++++++++++++ tests/integration/test_camera_mock.py | 73 +++++++++++++++++ 7 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 tests/integration/fixtures/camera_mock.yaml create mode 100644 tests/integration/fixtures/external_components/mock_camera/__init__.py create mode 100644 tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp create mode 100644 tests/integration/fixtures/external_components/mock_camera/mock_camera.h create mode 100644 tests/integration/test_camera_mock.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 73b4f3e5bd..2eb8c21c73 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #else #error "No frame helper defined" #endif -#ifdef USE_CAMERA - if (camera::Camera::instance() != nullptr) { - this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; - } -#endif } void APIConnection::start() { @@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() { if (!this->image_reader_) return; + const auto *cam = camera::Camera::instance(); // Send as many chunks as possible without blocking while (this->image_reader_->available()) { if (!this->helper_->can_write_without_blocking()) @@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.key = cam->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); + msg.device_id = cam->get_device_id(); #endif if (!this->send_message(msg)) { @@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() { void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; - if (!this->image_reader_) + if (this->image_reader_ && this->image_reader_->available()) return; - if (this->image_reader_->available()) + if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE)) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) { - this->image_reader_->set_image(std::move(image)); - // Try to send immediately to reduce latency - this->try_send_camera_image_(); + if (!this->image_reader_) { + // Created on the first image this connection will send, so connections + // that never receive one never pay for a reader. Only a registered + // camera's listener can reach this, so instance() is non-null here. + this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; } + this->image_reader_->set_image(std::move(image)); + // Try to send immediately to reduce latency + this->try_send_camera_image_(); } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *camera = static_cast(entity); diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index bf80b42e54..433361d298 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -103,7 +103,8 @@ struct CameraImageSpec { /** Abstract camera base class. Collaborates with API. * 1) API server starts and registers as a listener (add_listener) * to receive new images from the camera. - * 2) New API client connects and creates a new image reader (create_image_reader). + * 2) API connection creates an image reader (create_image_reader) when it receives + * the first image it will send. * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. diff --git a/tests/integration/fixtures/camera_mock.yaml b/tests/integration/fixtures/camera_mock.yaml new file mode 100644 index 0000000000..fa354d341f --- /dev/null +++ b/tests/integration/fixtures/camera_mock.yaml @@ -0,0 +1,19 @@ +esphome: + name: camera-mock-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +mock_camera: + name: Mock Camera + # Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across + # multiple CameraImageResponse chunks and the client must reassemble. + # Must match IMAGE_SIZE in test_camera_mock.py. + image_size: 4096 diff --git a/tests/integration/fixtures/external_components/mock_camera/__init__.py b/tests/integration/fixtures/external_components/mock_camera/__init__.py new file mode 100644 index 0000000000..57aaf07ab9 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core.entity_helpers import setup_entity +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/tests"] +AUTO_LOAD = ["camera"] + +CONF_IMAGE_SIZE = "image_size" + +mock_camera_ns = cg.esphome_ns.namespace("mock_camera") +MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase) + +CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(MockCamera), + cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + var = cg.new_Pvariable(config[CONF_ID]) + await setup_entity(var, config, "camera") + await cg.register_component(var, config) + cg.add(var.set_image_size(config[CONF_IMAGE_SIZE])) diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp new file mode 100644 index 0000000000..64ed6bfe5c --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp @@ -0,0 +1,30 @@ +#include "mock_camera.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mock_camera { + +static const char *const TAG = "mock_camera"; + +void MockCamera::loop() { + uint8_t requesters = this->single_requesters_ | this->stream_requesters_; + if (requesters == 0) + return; + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS) + return; + this->last_frame_ms_ = now; + this->single_requesters_ = 0; + + auto image = std::make_shared(this->image_size_, this->frame_counter_, requesters); + ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_, + requesters); + this->frame_counter_++; + for (auto *listener : this->listeners_) { + listener->on_camera_image(image); + } +} + +void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); } + +} // namespace esphome::mock_camera diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.h b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h new file mode 100644 index 0000000000..bcf40bba67 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h @@ -0,0 +1,80 @@ +#pragma once + +#include "esphome/components/camera/camera.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::mock_camera { + +/** Deterministic in-memory camera image. + * Byte i of frame N is (N + i) & 0xFF so tests can validate + * reassembled data from just the first byte. + */ +class MockCameraImage : public camera::CameraImage { + public: + MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters) + : data_(new uint8_t[size]), size_(size), requesters_(requesters) { + for (size_t i = 0; i < size; i++) { + this->data_[i] = static_cast(frame_counter + i); + } + } + uint8_t *get_data_buffer() override { return this->data_.get(); } + size_t get_data_length() override { return this->size_; } + bool was_requested_by(camera::CameraRequester requester) const override { + return (this->requesters_ & (1 << requester)) != 0; + } + + protected: + std::unique_ptr data_; + size_t size_; + uint8_t requesters_; +}; + +class MockCameraImageReader : public camera::CameraImageReader { + public: + void set_image(std::shared_ptr image) override { + this->image_ = std::move(image); + this->offset_ = 0; + } + size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; } + uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; } + void consume_data(size_t consumed) override { this->offset_ += consumed; } + void return_image() override { + this->image_.reset(); + this->offset_ = 0; + } + + protected: + std::shared_ptr image_; + size_t offset_{0}; +}; + +/** Virtual camera producing deterministic frames on request or stream. */ +class MockCamera : public camera::Camera { + public: + void loop() override; + void dump_config() override; + + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } + camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); } + void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); } + void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); } + void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); } + + void set_image_size(uint32_t size) { this->image_size_ = size; } + + protected: + static constexpr uint32_t FRAME_INTERVAL_MS = 50; + + // Members ordered largest to smallest to minimize padding + std::vector listeners_; + uint32_t image_size_{1024}; + uint32_t last_frame_ms_{0}; + uint8_t frame_counter_{0}; + uint8_t single_requesters_{0}; + uint8_t stream_requesters_{0}; +}; + +} // namespace esphome::mock_camera diff --git a/tests/integration/test_camera_mock.py b/tests/integration/test_camera_mock.py new file mode 100644 index 0000000000..6819d7a6d4 --- /dev/null +++ b/tests/integration/test_camera_mock.py @@ -0,0 +1,73 @@ +"""Integration test for the camera API flow using a mock camera platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import CameraInfo, CameraState, EntityState +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Must match image_size in fixtures/camera_mock.yaml +IMAGE_SIZE = 4096 +STREAM_FRAMES = 3 + + +def _verify_frame(data: bytes) -> int: + """Verify the deterministic frame pattern and return the frame counter.""" + assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}" + counter = data[0] + assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), ( + "frame pattern mismatch" + ) + return counter + + +@pytest.mark.asyncio +async def test_camera_mock( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Single-image and stream requests deliver reassembled deterministic frames.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + camera = require_entity(entities, "mock_camera", CameraInfo) + + loop = asyncio.get_running_loop() + images: list[bytes] = [] + single_image: asyncio.Future[None] = loop.create_future() + stream_done: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + if not (isinstance(state, CameraState) and state.key == camera.key): + return + images.append(bytes(state.data)) + if not single_image.done(): + single_image.set_result(None) + elif len(images) >= STREAM_FRAMES and not stream_done.done(): + stream_done.set_result(None) + + client.subscribe_states(on_state) + + # Single image request: one complete frame arrives, reassembled + # from multiple chunks (4096 > 1390 byte packets) + client.request_single_image() + await asyncio.wait_for(single_image, timeout=10) + first_counter = _verify_frame(images[0]) + + # Stream request: multiple consecutive frames arrive + images.clear() + client.request_image_stream() + await asyncio.wait_for(stream_done, timeout=10) + + # Frames are distinct, ordered, and fresh per the mock's counter. + # Not exactly consecutive: the API drops frames by design while the + # previous image is still being sent, so allow small gaps. + counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]] + for prev, cur in zip(counters, counters[1:], strict=False): + assert cur != prev, f"duplicate frames: {counters}" + assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}" + assert counters[0] != first_counter, "stream should produce new frames" From e1c279718fafe3884101efdbff3a9d1a1d5ed529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:57:12 -0700 Subject: [PATCH 077/470] [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) --- esphome/components/ld2420/ld2420.cpp | 2 -- esphome/components/ld2420/ld2420.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); From ebb0923362601879742a870d39e114b2279258cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:00 -0500 Subject: [PATCH 078/470] Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 876b13793c..61011f2fbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.2 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 07e8b303b9a4f588285795b841c8ae7061d31712 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:52:56 -0700 Subject: [PATCH 079/470] [ota] Shorten platform backend TAG strings (#18438) --- esphome/components/ota/ota_backend_arduino_libretiny.cpp | 2 +- esphome/components/ota/ota_backend_arduino_rp2.cpp | 2 +- esphome/components/ota/ota_backend_esp8266.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_host.cpp | 2 +- esphome/components/ota/ota_bootloader_esp_idf.cpp | 2 +- esphome/components/ota/ota_partitions_esp_idf.cpp | 2 +- esphome/components/ota/ota_signature_esp_idf.cpp | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 4cc99202a7..231c4d2dd2 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -9,7 +9,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_libretiny"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index b35eb38c12..48725b1265 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 6a678fb419..2a6a9e08b1 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -46,7 +46,7 @@ static constexpr size_t MIN_BUFFER_SIZE = 256; namespace esphome::ota { -static const char *const TAG = "ota.esp8266"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 108605e4c9..eb23ad82dd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -15,7 +15,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index ee503a49e1..89e3f99e1e 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -27,7 +27,7 @@ namespace esphome::ota { namespace { -const char *const TAG = "ota.host"; +const char *const TAG = "ota"; constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 264218a3df..57b5529350 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { // Register the bootloader partition diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index a7fc709313..d2b1196de6 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index b327988d2d..71dcc0eb83 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -31,7 +31,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; // Route the "Signature check: " prefix (and its per-block form) through one // shared format string each, so the prefix is pooled once by the linker instead From c01f24553c129327ef591cb9e7198369918663b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:29 -0700 Subject: [PATCH 080/470] [uart] Shorten platform backend TAG strings (#18439) --- esphome/components/uart/uart_component_esp8266.cpp | 2 +- esphome/components/uart/uart_component_esp_idf.cpp | 2 +- esphome/components/uart/uart_component_host.cpp | 2 +- esphome/components/uart/uart_component_libretiny.cpp | 2 +- esphome/components/uart/uart_component_rp2.cpp | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index fc1509f737..2f8b4dbd11 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -14,7 +14,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_esp8266"; +static const char *const TAG = "uart"; bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 93e43e0372..a61339feb4 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -21,7 +21,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.idf"; +static const char *const TAG = "uart"; /// Check if a pin number matches one of the default UART0 GPIO pins. /// These pins may have residual IOMUX state from the ROM bootloader that diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 5bb7a49726..63b5631564 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -98,7 +98,7 @@ speed_t get_baud(int baud) { namespace esphome::uart { -static const char *const TAG = "uart.host"; +static const char *const TAG = "uart"; HostUartComponent::~HostUartComponent() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index fbf0c20ded..4eacd980db 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -16,7 +16,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.lt"; +static const char *const TAG = "uart"; static const char *const UART_TYPE[] = { "hardware", diff --git a/esphome/components/uart/uart_component_rp2.cpp b/esphome/components/uart/uart_component_rp2.cpp index 9cc3009a22..ffb9bc0f2d 100644 --- a/esphome/components/uart/uart_component_rp2.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -13,7 +13,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2"; +static const char *const TAG = "uart"; uint16_t RP2UartComponent::get_config() { uint16_t config = 0; From d1a7b8df8b616cd49affa5a70298fbdfce07d6be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:42 -0700 Subject: [PATCH 081/470] [adc] Shorten platform TAG strings (#18441) --- esphome/components/adc/adc_sensor_common.cpp | 2 +- esphome/components/adc/adc_sensor_esp32.cpp | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 2 +- esphome/components/adc/adc_sensor_libretiny.cpp | 2 +- esphome/components/adc/adc_sensor_rp2.cpp | 2 +- esphome/components/adc/adc_sensor_zephyr.cpp | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 16c86aee18..5ca58df10e 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.common"; +static const char *const TAG = "adc"; const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index a761b37749..a0f7a1ed08 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.esp32"; +static const char *const TAG = "adc"; adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index e4f2f82f08..77a192e025 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc.esp8266"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index d9b9f50be1..dfa545b395 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.libretiny"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 8652a46029..ce665e8501 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2"; +static const char *const TAG = "adc"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c3632b00e2..bf45059740 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.zephyr"; +static const char *const TAG = "adc"; void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { From 37bea1c1538c830691e95bcce398e816069f7556 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:48 -0700 Subject: [PATCH 082/470] [spi] Shorten platform backend TAG strings (#18442) --- esphome/components/spi/spi_arduino.cpp | 2 +- esphome/components/spi/spi_esp_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index a3e09d2800..14428bed62 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const char *const TAG = "spi-esp-arduino"; +static const char *const TAG = "spi"; class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 0731078eec..d5d5053117 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #ifdef USE_ESP32 -static const char *const TAG = "spi-esp-idf"; +static const char *const TAG = "spi"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. class SPIDelegateHw : public SPIDelegate { From 47a58dd7991affd47b61df0cd491076d77ceff18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:56 -0700 Subject: [PATCH 083/470] [internal_temperature] Shorten platform TAG strings (#18443) --- .../internal_temperature/internal_temperature_bk72xx.cpp | 2 +- .../internal_temperature/internal_temperature_esp32.cpp | 2 +- .../internal_temperature/internal_temperature_rp2.cpp | 2 +- .../internal_temperature/internal_temperature_zephyr.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index b7332ee81f..91f47d831f 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.bk72xx"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 64fe3707b1..2c6fda2af4 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.esp32"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 2e408b3b01..c4ab33b0a5 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -16,7 +16,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2"; +static const char *const TAG = "internal_temperature"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index be72ab6f51..50c597f6f1 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.zephyr"; +static const char *const TAG = "internal_temperature"; static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); From e0d28d7f5c9128ca98436ec7d4a25cc5ebe91914 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:03 -0700 Subject: [PATCH 084/470] [http_request] Shorten platform backend TAG strings (#18444) --- esphome/components/http_request/http_request_arduino.cpp | 2 +- esphome/components/http_request/http_request_host.cpp | 2 +- esphome/components/http_request/http_request_idf.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 84333e7169..43ab2e5b53 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.arduino"; +static const char *const TAG = "http_request"; #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 85c6e8b3c7..cf231e20bd 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -14,7 +14,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.host"; +static const char *const TAG = "http_request"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a437540241..ddff954950 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.idf"; +static const char *const TAG = "http_request"; static constexpr uint32_t ERROR_DURATION_MS = 1000; void HttpRequestIDF::dump_config() { From 1f000ba66899be59be0aaa655692757111c8f435 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:17 -0700 Subject: [PATCH 085/470] [mqtt] Shorten esp32 backend TAG string (#18446) --- esphome/components/mqtt/mqtt_backend_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 499a330730..09eb5f97dc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.idf"; +static const char *const TAG = "mqtt"; bool MQTTBackendESP32::initialize_() { mqtt_cfg_.broker.address.hostname = this->host_.c_str(); From 1f4fcead38d9897e37c6c3ec059c86988408e5b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:29 -0700 Subject: [PATCH 086/470] [nextion] Shorten upload TAG strings (#18448) --- esphome/components/nextion/nextion_upload_arduino.cpp | 2 +- esphome/components/nextion/nextion_upload_esp32.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 2f3377d950..f02f32d5ca 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -13,7 +13,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.arduino"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index e2d5ae8ad7..c4dc74b5d3 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.esp32"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). From ebe93e2c684c46ea960262c05a0914b5f6bda61e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:35 -0700 Subject: [PATCH 087/470] [bluetooth_connection] Shorten platform TAG strings (#18449) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- .../bluetooth_connection/bluetooth_connection_rp2.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 076c77b18e..15f854239d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -20,7 +20,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.bluedroid"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::FAST_CONN_TIMEOUT; using ble_device_base::FAST_MAX_CONN_INTERVAL; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 855c895196..16a89dcfdd 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -15,7 +15,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.rp2"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::ESPBTUUID; using ble_device_base::GATT_ERR_NOT_CONNECTED; From 3d9fecb56229ddefc7eaf246e23d4ed656f28f03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:52 -0700 Subject: [PATCH 088/470] [remote_receiver] Shorten esp32 TAG string (#18447) --- esphome/components/remote_receiver/remote_receiver_rmt.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 596608a4d0..632ca9763a 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -9,7 +9,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver.esp32"; +static const char *const TAG = "remote_receiver"; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; From f6c7434b2abb0cba04ce08e669c14b380b9622bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:55 -0700 Subject: [PATCH 089/470] [i2c] Shorten platform backend TAG strings (#18440) --- esphome/components/i2c/i2c_bus_arduino.cpp | 2 +- esphome/components/i2c/i2c_bus_esp_idf.cpp | 2 +- esphome/components/i2c/i2c_bus_host.cpp | 2 +- esphome/components/i2c/i2c_bus_zephyr.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index cc036b12c3..39a6aec774 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -9,7 +9,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.arduino"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 4aca4f0fae..7ca9537e2d 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.idf"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 17279fda50..303944636b 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.host"; +static const char *const TAG = "i2c"; HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 1eb9944dcb..ffdd2ba8bb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.zephyr"; +static const char *const TAG = "i2c"; static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { From 031a038b49318018ca1aeee08cc111c2aa5e9b9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:56:37 -0700 Subject: [PATCH 090/470] [deep_sleep] Shorten bk72xx TAG string (#18445) --- esphome/components/deep_sleep/deep_sleep_bk72xx.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 73e0331c76..2c97dc3211 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep.bk72xx"; +static const char *const TAG = "deep_sleep"; #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { From 6d20ebc66b309df4d413d316f369ffc48742f4cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 10:18:43 -0700 Subject: [PATCH 091/470] [socket] Shorten lwip TAG string (#18450) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..b80a394eec 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -43,7 +43,7 @@ namespace esphome::socket { // (Ethernet). On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT -static const char *const TAG = "socket.lwip"; +static const char *const TAG = "socket"; // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) From 27483a4101e2098cefff3a5c7c56d0b1594b2506 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 11:15:31 -0700 Subject: [PATCH 092/470] [core] Retry gh CLI calls on transient network errors in CI scripts (#18292) --- script/ci_memory_impact_comment.py | 24 +++--- script/helpers.py | 91 +++++++++++++++++++++- tests/script/test_helpers.py | 121 +++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 15 deletions(-) diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 0908b99595..33ca84d76c 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -20,17 +20,21 @@ from jinja2 import Environment, FileSystemLoader sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position +from helpers import run_gh_command # noqa: E402 # Comment marker to identify our memory impact comments COMMENT_MARKER = "" -def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProcess: - """Run a gh CLI command with error handling. +def run_gh_command_logged( + args: list[str], operation: str, *, retry: bool = True +) -> subprocess.CompletedProcess: + """Run a gh CLI command with retries and error reporting. Args: args: Command arguments (including 'gh') operation: Description of the operation for error messages + retry: Pass False for non-idempotent commands (see run_gh_command) Returns: CompletedProcess result @@ -39,12 +43,7 @@ def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProce subprocess.CalledProcessError: If command fails (with detailed error output) """ try: - return subprocess.run( - args, - check=True, - capture_output=True, - text=True, - ) + return run_gh_command(args, retry=retry) except subprocess.CalledProcessError as e: print( f"ERROR: {operation} failed with exit code {e.returncode}", file=sys.stderr @@ -472,7 +471,7 @@ def find_existing_comment(pr_number: str) -> str | None: print(f"DEBUG: Looking for existing comment on PR #{pr_number}", file=sys.stderr) # Use gh api to get comments directly - this returns the numeric id field - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -535,7 +534,7 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None: """ print(f"DEBUG: Updating existing comment {comment_id}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -562,9 +561,12 @@ def create_new_comment(pr_number: str, comment_body: str) -> None: """ print(f"DEBUG: Posting new comment on PR #{pr_number}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + # Creating a comment is not idempotent: a retry after a dropped response + # could post the same comment twice, so fail on the first error instead. + result = run_gh_command_logged( ["gh", "pr", "comment", pr_number, "--body", comment_body], operation="Create PR comment", + retry=False, ) print(f"DEBUG: Post response: {result.stdout}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 7cc001d92f..11549808ff 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -469,6 +469,77 @@ def get_target_branch() -> str | None: return None +# Substrings (matched case-insensitively against gh's stderr) that identify +# transient failures worth retrying: server errors (HTTP 5xx) and dropped or +# failed connections. Permanent failures (bad auth, missing PR, the 300-file +# diff limit) never match so callers see them immediately. Phrases are +# anchored so gh's GraphQL "Could not resolve to a PullRequest" (a missing +# PR) never classifies as a DNS failure. +_TRANSIENT_GH_ERROR_RE = re.compile( + r"http 5\d\d" + r"|timed out|timeout" + r"|connection (?:reset|refused|closed)" + r"|no such host|could not resolve host" + # gh intercepts DNS errors and prints its own "error connecting to + # " text; the Go phrases above are kept as a hedge in case a + # future gh stops swallowing the underlying error + r"|error connecting to" + r"|failed to verify certificate" + # Go reports a server-closed connection as 'Post "": EOF'; the + # quote-and-colon anchor keeps a URL or message body containing the + # letters from matching + r"|unexpected eof" + r'|": eof' + r"|network is unreachable" + r"|temporary failure" +) + +# Same retry policy as git network commands in esphome/git.py: 3 attempts +# with 2s/4s backoff. +_GH_MAX_ATTEMPTS = 3 + + +def run_gh_command( + args: list[str], *, retry: bool = True +) -> subprocess.CompletedProcess[str]: + """Run a gh CLI command, retrying transient network and server failures. + + Args: + args: Full command line, including the leading "gh". + retry: Pass False for commands that are not idempotent (e.g. posting + a comment), where a retry after a dropped response could repeat + a write that already succeeded server-side. + + Returns: + CompletedProcess with captured text output. + + Raises: + subprocess.CalledProcessError: If the command fails with a permanent + error, or is still failing after the retries are exhausted. + """ + attempts = _GH_MAX_ATTEMPTS if retry else 1 + attempt = 0 + while True: + try: + return subprocess.run( + args, check=True, capture_output=True, text=True, close_fds=False + ) + except subprocess.CalledProcessError as err: + attempt += 1 + stderr = err.stderr or "" + if attempt >= attempts or not _TRANSIENT_GH_ERROR_RE.search(stderr.lower()): + raise + delay = 2**attempt + # Only the leading arguments: comment-update calls carry the + # whole multi-KB comment body in the argument list + print( + f"WARNING: {' '.join(args[:3])} failed: {stderr.strip()}; " + f"retrying in {delay}s (attempt {attempt}/{attempts})", + file=sys.stderr, + ) + time.sleep(delay) + + @cache def _get_changed_files_github_actions() -> list[str] | None: """Get changed files in GitHub Actions environment. @@ -542,10 +613,22 @@ def changed_files(branch: str | None = None) -> list[str]: def _get_changed_files_from_command(command: list[str]) -> list[str]: - """Run a git command to get changed files and return them as a list.""" - proc = subprocess.run(command, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") + """Run a git or gh command to get changed files and return them as a list.""" + if command[0] == "gh": + try: + proc = run_gh_command(command) + except subprocess.CalledProcessError as e: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {e.stderr}" + ) from e + else: + proc = subprocess.run( + command, capture_output=True, text=True, check=False, close_fds=False + ) + if proc.returncode != 0: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}" + ) changed_files = splitlines_no_ends(proc.stdout) cwd = Path.cwd() diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 077b6ef23e..a07e56cea5 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -20,6 +20,7 @@ changed_files = helpers.changed_files filter_changed = helpers.filter_changed get_changed_components = helpers.get_changed_components _get_changed_files_from_command = helpers._get_changed_files_from_command +run_gh_command = helpers.run_gh_command _get_pr_number_from_github_env = helpers._get_pr_number_from_github_env _get_changed_files_github_actions = helpers._get_changed_files_github_actions _filter_changed_ci = helpers._filter_changed_ci @@ -1872,3 +1873,123 @@ def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> def test_base_python_changed(files: list[str], expected: bool) -> None: """Only Python modules directly in esphome/ count as base Python changes.""" assert helpers.base_python_changed(files) is expected + + +def _gh_error(stderr: str) -> subprocess.CalledProcessError: + return subprocess.CalledProcessError(1, ["gh"], output="", stderr=stderr) + + +def _gh_success(stdout: str = "ok\n") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(["gh"], 0, stdout=stdout, stderr="") + + +def test_run_gh_command_success() -> None: + """A successful command returns without retrying.""" + with patch("helpers.subprocess.run", return_value=_gh_success()) as mock_run: + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + mock_run.assert_called_once() + + +@pytest.mark.parametrize( + "second_error", + [ + ( + 'Post "https://api.github.com/graphql": tls: failed to verify' + " certificate: x509: certificate is not valid for any names," + " but wanted to match api.github.com" + ), + 'Post "https://api.github.com/graphql": EOF', + ( + "error connecting to api.github.com\n" + "check your internet connection or https://githubstatus.com" + ), + ], +) +def test_run_gh_command_retries_transient_error(second_error: str) -> None: + """Transient server errors are retried with 2s/4s backoff.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=[ + _gh_error("HTTP 502: 502 Bad Gateway (https://api.github.com/graphql)"), + _gh_error(second_error), + _gh_success(), + ], + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + ): + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + assert mock_run.call_count == 3 + assert [call.args[0] for call in mock_sleep.call_args_list] == [2, 4] + + +def test_run_gh_command_gives_up_after_max_attempts() -> None: + """A persistent transient error raises after the third attempt.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 503: Service Unavailable"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert mock_run.call_count == 3 + assert mock_sleep.call_count == 2 + + +@pytest.mark.parametrize( + "stderr", + [ + "HTTP 404: Not Found (https://api.github.com/repos/x)", + "HTTP 401: Bad credentials", + "HTTP 403: API rate limit exceeded for installation ID 123.", + "diff exceeded the maximum number of changed files (300)", + ( + "GraphQL: Could not resolve to a PullRequest with the number of 999999." + " (repository.pullRequest)" + ), + ], +) +def test_run_gh_command_permanent_error_not_retried(stderr: str) -> None: + """Permanent failures raise immediately without any retry.""" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_gh_command_no_retry_for_non_idempotent_commands() -> None: + """retry=False fails on the first error even when it looks transient.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 502: 502 Bad Gateway"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "comment", "123", "--body", "x"], retry=False) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None: + """Failures from gh surface stderr so callers can detect the 300-file limit.""" + stderr = "diff exceeded the maximum number of changed files (300)" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)), + pytest.raises(Exception, match="maximum number of changed files"), + ): + _get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"]) From a3af82867b3cebfbf6af9a3be9c54731f0e8d544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 12:00:39 -0700 Subject: [PATCH 093/470] [api] Bump noise-c to 0.1.18 (#18451) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..5ca9484336 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + cg.add_library("esphome/noise-c", "0.1.18") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..2c22523be5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 6d20943a9c17a994338ba176c2709de80897d1df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:38:06 -0400 Subject: [PATCH 094/470] Bump esphome/workflows/.github/workflows/stale.yml from 61fd37a044cad4e9aa4303027b2a61b6a34da855 to a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 (#18464) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3c471b6efb..aa31094f81 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main + uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From 9bc72529a6a4dab3bcfb6d182b1a5aa2f0b66b9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:08 -0500 Subject: [PATCH 095/470] Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 in /.github/actions/restore-python (#18462) 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 daf041819c..6279a26dc4 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 4712c15c75d0457aea5d75ff717a0f2bc7171d00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:24 -0500 Subject: [PATCH 096/470] Bump github/codeql-action/init from 4.37.6 to 4.37.7 (#18466) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e164cd9f6..f01441cdfd 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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 3d5f6f692f4916fd2923c490d28a5df3287c087c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:45:41 -0500 Subject: [PATCH 097/470] Bump filelock from 3.32.2 to 3.32.3 (#18461) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61011f2fbd..4b1708637d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.2 # native esp-idf toolchain global cache dir -filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 95180067245bf49d5154889082323c91450145c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:46:04 -0500 Subject: [PATCH 098/470] Bump ruff from 0.16.2 to 0.16.3 (#18458) 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 95ee97437d..cedc107b17 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.2 # also change in .pre-commit-config.yaml when updating +ruff==0.16.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating prek==0.4.13 # also change in .github/workflows/ci.yml when updating From 346ba7e831d21d5b005276ca1085fcc570ce3ce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:47:18 -0500 Subject: [PATCH 099/470] Bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#18467) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f01441cdfd..103cecc1f9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: "/language:${{matrix.language}}" From 55726120db6bfaf8ed8fc699a92580f59b7b0e46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:47:28 -0500 Subject: [PATCH 100/470] Bump esphome/workflows/.github/workflows/lock.yml from 2026.7.0 to 2026.8.1 (#18465) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index ec736a2002..e09e9bf2d1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 From 199e368fe2dfca47d8c59796bb54d1d292934396 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:57:50 -0500 Subject: [PATCH 101/470] Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#18463) 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 820081cc46..1ccff96f24 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 026c2ba27a..cd1a382c21 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -367,7 +367,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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1095,7 +1095,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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a299e76584..9100064176 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From e32329cc11a38d9b0a70bbd401b1e0ea6a062423 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 15:00:37 -0500 Subject: [PATCH 102/470] [core] Sync pre-commit ruff hook with requirements (0.16.3) (#18469) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99a4f40201..0ea799aa4d 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.16.0 + rev: v0.16.3 hooks: # Run the linter. - id: ruff From e45b4e493886e089880cef1b77a4ae367ca0aea9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:26:24 +1200 Subject: [PATCH 103/470] [core] Make FINAL_VALIDATE_SCHEMA functions return None (#18457) --- esphome/components/bk72xx_ble/__init__.py | 3 +-- esphome/components/captive_portal/__init__.py | 4 +--- esphome/components/dsmr/__init__.py | 4 +--- esphome/components/emontx/__init__.py | 4 ++-- esphome/components/epaper_spi/display.py | 3 +-- esphome/components/esp32/__init__.py | 4 +--- esphome/components/esp32_ble/__init__.py | 4 +--- esphome/components/esp32_ble_server/__init__.py | 3 +-- esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/ethernet/__init__.py | 3 +-- esphome/components/factory_reset/__init__.py | 3 +-- esphome/components/file/image.py | 3 +-- .../components/gpio/binary_sensor/__init__.py | 10 ++++------ esphome/components/growatt_solar/sensor.py | 4 ++-- esphome/components/haier/climate.py | 3 +-- esphome/components/haier/switch/__init__.py | 3 +-- esphome/components/havells_solar/sensor.py | 4 ++-- esphome/components/hub75/display.py | 4 +--- esphome/components/improv_serial/__init__.py | 3 +-- esphome/components/inkplate/display.py | 3 +-- esphome/components/it8951/display.py | 3 +-- esphome/components/kuntze/sensor.py | 4 ++-- esphome/components/ld6002b/button/__init__.py | 4 +--- esphome/components/ld6002b/number/__init__.py | 6 ++---- esphome/components/light/__init__.py | 6 ++---- esphome/components/mcp4461/output/__init__.py | 5 ++--- esphome/components/mdns/__init__.py | 5 ++--- esphome/components/mipi_dsi/display.py | 3 +-- esphome/components/mipi_rgb/display.py | 3 +-- esphome/components/mitsubishi_cn105/climate.py | 6 +++--- .../components/modbus_controller/__init__.py | 6 ++---- esphome/components/modbus_server/__init__.py | 4 ++-- .../packet_transport/binary_sensor.py | 6 +++--- esphome/components/provisioning/__init__.py | 3 +-- esphome/components/pzemac/sensor.py | 4 ++-- esphome/components/pzemdc/sensor.py | 4 ++-- esphome/components/router/speaker/__init__.py | 3 +-- esphome/components/rp2040_ble/__init__.py | 3 +-- esphome/components/sdm_meter/sensor.py | 4 ++-- esphome/components/sds011/sensor.py | 3 +-- esphome/components/selec_meter/sensor.py | 4 ++-- esphome/components/tinyusb/__init__.py | 3 +-- esphome/components/web_server/__init__.py | 3 +-- esphome/components/zephyr_pwm/output.py | 3 +-- esphome/components/zwave_proxy/__init__.py | 4 +--- tests/component_tests/esp32_hosted/test_init.py | 2 +- tests/component_tests/image/test_init.py | 17 +++++++++-------- .../provisioning/test_provisioning.py | 6 +++--- 48 files changed, 79 insertions(+), 123 deletions(-) diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index b58464a1f6..81073c9b02 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -68,12 +68,11 @@ def _unsupported_family_message(family: str) -> str | None: return None -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # Warn only: a hard error here would break the validate-only CI fixtures, # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index d62c718097..8e5274f58f 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() wifi_conf = full_config.get("wifi") @@ -88,8 +88,6 @@ def _final_validate(config: ConfigType) -> ConfigType: socket.consume_sockets(3, "captive_portal")(config) socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 34f37ace35..eaf36d34fa 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -88,7 +88,7 @@ async def to_code(config): cg.add_library("esphome/dsmr_parser", "1.9.0") -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() for uart_conf in full_config["uart"]: @@ -102,7 +102,5 @@ def final_validate(config: ConfigType) -> ConfigType: ) break - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index a2d4349698..3f83578926 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -59,7 +59,7 @@ CONFIG_SCHEMA = ( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Count sensors registered to this hub (IDs are resolved at final_validate stage) @@ -95,7 +95,7 @@ def final_validate(config: ConfigType) -> ConfigType: parity="NONE", stop_bits=1, ) - return schema(config) + schema(config) FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 0b82850f1e..e9da924de5 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -153,7 +153,7 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config) -> None: spi.final_validate_device_schema( "epaper_spi", require_miso=False, require_mosi=True )(config) @@ -170,7 +170,6 @@ def _final_validate(config): config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7263571d69..7d43c3ac07 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1368,7 +1368,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: return config -def final_validate(config): +def final_validate(config) -> None: # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1629,8 +1629,6 @@ def final_validate(config): if errs: raise cv.MultipleInvalid(errs) - return config - CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 935d8b1b7e..f099c68e57 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -443,7 +443,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config): +def final_validation(config) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -514,8 +514,6 @@ def final_validation(config): # For newer chips (C3/S3/etc), different configs are used automatically add_idf_sdkconfig_option("CONFIG_BTDM_CTRL_BLE_MAX_CONN", max_connections) - return config - FINAL_VALIDATE_SCHEMA = final_validation diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index ea2a9667d7..855a3be29b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -307,7 +307,7 @@ def create_device_information_service(config): return config -def final_validate_config(config): +def final_validate_config(config) -> None: # Validate max_clients does not exceed esp32_ble max_connections max_clients = config[CONF_MAX_CLIENTS] if max_clients > 1: @@ -355,7 +355,6 @@ def final_validate_config(config): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value" ) - return config def validate_value_type(value_config): diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index d3432fb461..7dc61ce382 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -126,7 +126,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # The esp_hosted releases compatible with older ESP-IDF versions crash at # boot with a heap double free in the SDIO RX path (fixed in esp_hosted # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. @@ -136,7 +136,6 @@ def _final_validate(config: ConfigType) -> ConfigType: "Remove the framework version from your configuration to use the " "recommended version, or pin a version at or above 5.3." ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f3c77baaae..5eda0fc12c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -767,7 +767,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: raise cv.Invalid(error_msg, path=pin_path) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Final validation for Ethernet component.""" # Allow ethernet + wifi coexistence only when both are declared in network: priority:. if "wifi" in fv.full_config.get(): @@ -787,7 +787,6 @@ def _final_validate(config: ConfigType) -> ConfigType: _final_validate_spi(config) _final_validate_rmii_pins(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 818a53c0ed..d5d5d2ecb5 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -60,14 +60,13 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): raise cv.Invalid( "'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index b54c3f2adf..d340d21490 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -225,7 +225,7 @@ def image_schema(class_: MockObjClass = Image_) -> cv.Schema: ) -def validate_image_final(config: ConfigType) -> ConfigType: +def validate_image_final(config: ConfigType) -> None: """Per-entry final validation, shared by file-backed image platforms. For LVGL 9 the default byte order for RGB565 images is little-endian, so @@ -240,7 +240,6 @@ def validate_image_final(config: ConfigType) -> ConfigType: ) else: config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" - return config async def new_image(config: ConfigType) -> MockObj: diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 43358baedb..703806670c 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -68,10 +68,10 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config): +def _final_validate(config) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: - return config + return # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. @@ -82,7 +82,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return pin_num = config[CONF_PIN][CONF_NUMBER] @@ -96,7 +96,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return # When a pin is shared, interrupts can interfere with other components # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. @@ -120,8 +120,6 @@ def _final_validate(config): pin_num, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index d1f0069341..d62486f5ec 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -163,8 +163,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("growatt_solar", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 424ef46392..70ae36f528 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -424,7 +424,7 @@ async def power_action_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if CONF_LOGGER in full_config: _level = "NONE" @@ -448,7 +448,6 @@ def _final_validate(config): raise cv.Invalid( f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/switch/__init__.py b/esphome/components/haier/switch/__init__.py index acff0cf265..99ffcb37af 100644 --- a/esphome/components/haier/switch/__init__.py +++ b/esphome/components/haier/switch/__init__.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() for switch_type in [CONF_BEEPER, CONF_QUIET_MODE]: # Check switches that are only supported for HonClimate @@ -72,7 +72,6 @@ def _final_validate(config): raise cv.Invalid( f"{switch_type} switch is only supported for hon climate" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d18ae0d9af..8eafe1d9d6 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -217,8 +217,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("havells_solar", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("havells_solar", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index a404fbbade..24b8197073 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -315,7 +315,7 @@ def _validate_config(config: ConfigType) -> ConfigType: return config -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate requirements when using HUB75 display.""" # Local imports to avoid circular dependencies from esphome.components.esp32 import get_esp32_variant @@ -381,8 +381,6 @@ def _final_validate(config: ConfigType) -> ConfigType: if errs: raise cv.MultipleInvalid(errs) - return config - FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 4266f5b78b..3e2a6db1bc 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -22,7 +22,7 @@ CONFIG_SCHEMA = ( ) -def validate_logger(config): +def validate_logger(config) -> None: logger_conf = fv.full_config.get()[CONF_LOGGER] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") @@ -33,7 +33,6 @@ def validate_logger(config): raise cv.Invalid( "improv_serial does not support the selected logger hardware_uart" ) - return config FINAL_VALIDATE_SCHEMA = validate_logger diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 47c8c898e5..a0c0d5dc18 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -146,13 +146,12 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_cpu_frequency(config): +def _validate_cpu_frequency(config) -> None: esp32_config = fv.full_config.get()[PLATFORM_ESP32] if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": raise cv.Invalid( "Inkplate requires 240MHz CPU frequency (set in esp32 component)" ) - return config FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 51c5fc6118..bdc68b5257 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -336,7 +336,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config): +def _final_validate(config) -> None: # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( config @@ -351,7 +351,6 @@ def _final_validate(config): config[CONF_UPDATE_INTERVAL] = update_interval("never") else: config[CONF_SHOW_TEST_CARD] = True - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index c11ede9db6..2b53e70756 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -89,8 +89,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("kuntze", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("kuntze", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index c327c331c6..508d5c2bc6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -84,7 +84,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] @@ -108,8 +108,6 @@ def final_validate(config: ConfigType) -> ConfigType: path=[CONF_WAKE], ) - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 7e0be66c64..452e38d6e3 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -105,9 +105,9 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: if config.get(CONF_AREA_CONFIG) is None: - return config + return full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] @@ -132,8 +132,6 @@ def final_validate(config: ConfigType) -> ConfigType: path=[CONF_AREA_CONFIG], ) - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7c4d7ed431..b5b3d7c905 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -165,7 +165,7 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, @@ -173,7 +173,7 @@ def _final_validate(config: ConfigType) -> ConfigType: """ data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: - return config + return # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. @@ -217,8 +217,6 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 1642f6149a..99d4988c90 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -34,7 +34,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config): +def _validate_nonvolatile(config) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -49,7 +49,7 @@ def _validate_nonvolatile(config): f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the " f"volatile channels A-D; channels E-H are the nonvolatile registers themselves" ) - return config + return config.setdefault(CONF_NONVOLATILE, True) if config[CONF_NONVOLATILE]: @@ -62,7 +62,6 @@ def _validate_nonvolatile(config): raise cv.Invalid( f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'" ) - return config CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2d4f6085e5..24bce0cc3c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -62,7 +62,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config -def _require_network_interface(config: ConfigType) -> ConfigType: +def _require_network_interface(config: ConfigType) -> None: """Require a network interface for mDNS on Arduino/LEAmDNS platforms. On ESP8266 and RP2040 the C++ implementation needs at least one IP state @@ -71,7 +71,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType: that never initializes. """ if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): - return config + return full_config = fv.full_config.get() has_wifi = "wifi" in full_config has_ethernet = CORE.is_rp2 and "ethernet" in full_config @@ -81,7 +81,6 @@ def _require_network_interface(config: ConfigType) -> ConfigType: "mdns on this platform requires a network interface — " f"add a {options} component to your configuration." ) - return config CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index e5bb3d413d..8c125a9606 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -175,7 +175,7 @@ def _config_schema(config): return config -def _final_validate(config): +def _final_validate(config) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -183,7 +183,6 @@ def _final_validate(config): if not requires_buffer(config) and LVGL_DOMAIN not in global_config: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - return config CONFIG_SCHEMA = _config_schema diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index ebe930d37a..897088a257 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -248,7 +248,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _final_validate(config): +def _final_validate(config) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -260,7 +260,6 @@ def _final_validate(config): config = spi.final_validate_device_schema( "mipi_rgb", require_miso=False, require_mosi=True )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 64475d0e32..05a29b3665 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -143,11 +143,11 @@ def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: # Legacy climate-owned hub compatibility. Remove in 2027.2.0. -def _legacy_final_validate(config: ConfigType) -> ConfigType: +def _legacy_final_validate(config: ConfigType) -> None: if CONF_MITSUBISHI_CN105_ID in config: - return config + return - return uart.final_validate_device_schema( + uart.final_validate_device_schema( DOMAIN, require_rx=True, require_tx=True, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 1ce1e38d16..f3cd28d138 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -135,10 +135,8 @@ def validate_modbus_register(config): return config -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("modbus_controller", role="client")( - config - ) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("modbus_controller", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 16b956d7b5..249454b6b0 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -144,8 +144,8 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("modbus_server", role="server")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("modbus_server", role="server")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 09bbf91c99..3291ff2c59 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -44,10 +44,10 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config): +def _final_validate(config) -> None: if config[CONF_TYPE] != CONF_STATUS: # Only run this validation if a status sensor is being configured - return config + return full_config = fv.full_config.get() transport_path = full_config.get_path_for_id(config[CONF_TRANSPORT_ID])[:-1] transport_config = full_config.get_config_for_path(transport_path) @@ -56,7 +56,7 @@ def _final_validate(config): for p in transport_config[CONF_PROVIDERS] if p[CONF_NAME] == config[CONF_PROVIDER] ): - return config + return raise cv.Invalid( "Status sensor requires ping-pong to be enabled and the nominated provider to use encryption." ) diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py index 36fa69357a..9462bbb3b7 100644 --- a/esphome/components/provisioning/__init__.py +++ b/esphome/components/provisioning/__init__.py @@ -67,7 +67,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate the provisioning setup once every component has been processed. Sources register during their own config validation, so by final validation @@ -89,7 +89,6 @@ def _final_validate(config: ConfigType) -> ConfigType: "hardcoding them makes the window pointless.", ", ".join(sorted(data.hardcoded_credentials)), ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 4e228f6aa3..5bb734cb2d 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -98,8 +98,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("pzemac", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("pzemac", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 40cfe7b08a..b2c7c3a29d 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -80,8 +80,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("pzemdc", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("pzemdc", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index 2b2dc56433..18311416c3 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # Validate every configured output speaker can accept the router's format. # Switching to an output that can't reproduce the format the producer is # already sending would otherwise fail silently at runtime. @@ -76,7 +76,6 @@ def _final_validate(config: ConfigType) -> ConfigType: channels=config[CONF_NUM_CHANNELS], sample_rate=config[CONF_SAMPLE_RATE], )(proxy) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 332ea73a61..d2a08e9fc0 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -71,10 +71,9 @@ def validate_connection_slots() -> None: ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: _validate_board(config) validate_connection_slots() - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 46f5025080..125240e891 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -148,8 +148,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("sdm_meter", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 2d7b6b07e5..59ee6667a1 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: # In the default mode setup() writes config commands, so tx is required; # rx_only mode never writes, so tx is optional. uart.final_validate_device_schema( @@ -75,7 +75,6 @@ def _final_validate(config): parity="NONE", stop_bits=1, )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index ef4929c375..120b997605 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -164,8 +164,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("selec_meter", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("selec_meter", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 9e1ad3afc4..4c6f4db85b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if not any(name in full_config for name in _USB_CLASS_COMPONENTS): raise cv.Invalid( @@ -75,7 +75,6 @@ def _final_validate(config): "USB_SERIAL_JTAG on variants that support it " "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index c1887cc3fc..b2c0ea14ad 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -193,7 +193,7 @@ def _validate_no_sorting_component( ) -def _final_validate_sorting(config: ConfigType) -> ConfigType: +def _final_validate_sorting(config: ConfigType) -> None: if (webserver_version := config.get(CONF_VERSION)) != 3: _validate_no_sorting_component( CONF_SORTING_WEIGHT, webserver_version, fv.full_config.get() @@ -201,7 +201,6 @@ def _final_validate_sorting(config: ConfigType) -> ConfigType: _validate_no_sorting_component( CONF_SORTING_GROUP_ID, webserver_version, fv.full_config.get() ) - return config FINAL_VALIDATE_SCHEMA = _final_validate_sorting diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py index 54c04473e3..b7ee27f63c 100644 --- a/esphome/components/zephyr_pwm/output.py +++ b/esphome/components/zephyr_pwm/output.py @@ -102,9 +102,8 @@ def _allocate_blocks() -> None: _get_data().pwm_blocks = pwm_blocks -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: _allocate_blocks() - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index d88f9f7041..14b8474045 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -11,7 +11,7 @@ zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) -def final_validate(config): +def final_validate(config) -> None: full_config = fv.full_config.get() if (wifi_conf := full_config.get(CONF_WIFI)) and ( wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" @@ -20,8 +20,6 @@ def final_validate(config): f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy" ) - return config - CONFIG_SCHEMA = ( cv.Schema( diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py index cec81e4e83..5cc3f928cc 100644 --- a/tests/component_tests/esp32_hosted/test_init.py +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -19,7 +19,7 @@ def test_final_validate_accepts_supported_idf( PlatformFramework.ESP32_IDF, platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, ) - assert _final_validate({}) == {} + _final_validate({}) @pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 78462463b1..f52c477c85 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -371,27 +371,28 @@ def test_migrate_returns_none_for_invalid_legacy_shapes( def test_validate_image_final_defaults_to_little_endian() -> None: - out = validate_image_final({CONF_FILE: "x.png"}) - assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + config = {CONF_FILE: "x.png"} + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" def test_validate_image_final_keeps_little_endian( caplog: pytest.LogCaptureFixture, ) -> None: + config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} with caplog.at_level(logging.WARNING): - out = validate_image_final( - {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} - ) - assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" assert "big-endian" not in caplog.text def test_validate_image_final_warns_on_big_endian( caplog: pytest.LogCaptureFixture, ) -> None: + config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"} with caplog.at_level(logging.WARNING): - out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) - assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN" assert "big-endian" in caplog.text diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py index 07f5065241..d3a3771bbc 100644 --- a/tests/component_tests/provisioning/test_provisioning.py +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -37,7 +37,7 @@ def test_provisioning_accepts_a_registered_source( set_core_config(PlatformFramework.ESP32_IDF) register_source("network") # Should not raise. - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) def test_provisioning_warns_on_hardcoded_credentials( @@ -49,7 +49,7 @@ def test_provisioning_warns_on_hardcoded_credentials( register_source("network") report_hardcoded_credentials("wifi") with caplog.at_level(logging.WARNING): - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) assert "wifi" in caplog.text assert "credentials" in caplog.text @@ -62,7 +62,7 @@ def test_provisioning_no_warning_without_hardcoded_credentials( set_core_config(PlatformFramework.ESP32_IDF) register_source("network") with caplog.at_level(logging.WARNING): - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) assert "credentials" not in caplog.text From 7362c01c6744e0be6eae307a31486d59f8b50f49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 16:14:06 -0500 Subject: [PATCH 104/470] [core] Replace base64 lookup tables with arithmetic mapping (#18454) --- esphome/core/alloc_helpers.cpp | 16 ++++-- esphome/core/helpers.cpp | 24 ++++----- tests/components/core/test_helpers.cpp | 67 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index d9cfad70b9..f6130b7b78 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -88,9 +88,17 @@ std::string str_sprintf(const char *fmt, ...) { // --- Base64 helpers --- -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; +// Map a 6-bit value (0-63) to its base64 character arithmetically. +// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). +static inline char base64_char(uint8_t index) { + if (index < 26) + return 'A' + index; + if (index < 52) + return 'a' + (index - 26); + if (index < 62) + return '0' + (index - 52); + return index == 62 ? '+' : '/'; +} // Encode 3 input bytes to 4 base64 characters, append 'count' to ret. static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { @@ -101,7 +109,7 @@ static inline void base64_encode_triple(const char *char_array_3, int count, std char_array_4[3] = char_array_3[2] & 0x3f; for (int j = 0; j < count; j++) - ret += BASE64_CHARS[static_cast(char_array_4[j])]; + ret += base64_char(static_cast(char_array_4[j])); } std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 8c4442f1b2..bd08d3b63e 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -579,13 +579,8 @@ int8_t step_to_accuracy_decimals(float step) { return str.length() - dot_pos - 1; } -// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes) -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; - -// Helper function to find the index of a base64/base64url character in the lookup table. -// Returns the character's position (0-63) if found, or 0 if not found. +// Map a base64/base64url character to its 6-bit value (0-63) arithmetically. +// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). // Supports both standard base64 (+/) and base64url (-_) alphabets. // NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters. // This is safe because is_base64() is ALWAYS checked before calling this function, @@ -593,13 +588,18 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" // stops processing at the first invalid character due to the is_base64() check in its // while loop condition, making this edge case harmless in practice. static inline uint8_t base64_find_char(char c) { - // Handle base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63) - if (c == '-') + if (c >= 'A' && c <= 'Z') + return c - 'A'; + if (c >= 'a' && c <= 'z') + return c - 'a' + 26; + if (c >= '0' && c <= '9') + return c - '0' + 52; + // base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63) + if (c == '+' || c == '-') return 62; - if (c == '_') + if (c == '/' || c == '_') return 63; - const char *pos = strchr(BASE64_CHARS, c); - return pos ? (pos - BASE64_CHARS) : 0; + return 0; } // Check if character is valid base64 or base64url diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 5fb77ef753..3767b24d86 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -1,6 +1,7 @@ #include #include +#include "esphome/core/alloc_helpers.h" #include "esphome/core/helpers.h" namespace esphome::core::testing { @@ -213,4 +214,70 @@ TEST(BufAppendSepStr, Truncation) { EXPECT_EQ(end - buf, 7); } +// --- base64 encode/decode --- + +static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order +TEST(Base64, EncodeProducesCanonicalAlphabet) { + uint8_t bytes[48]; + size_t n = 0; + for (uint8_t i = 0; i < 64; i += 4) { + bytes[n++] = (i << 2) | ((i + 1) >> 4); + bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2); + bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3); + } + std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(encoded, BASE64_ALPHABET); +} + +// Decode the alphabet then re-encode: locks the encode and decode mappings together +TEST(Base64, DecodeCanonicalAlphabetRoundTrip) { + uint8_t buf[48]; + size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf)); + EXPECT_EQ(len, 48u); + std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(reencoded, BASE64_ALPHABET); +} + +TEST(Base64, DecodeBase64UrlMatchesStandard) { + std::string url = BASE64_ALPHABET; + for (char &c : url) { + if (c == '+') + c = '-'; + if (c == '/') + c = '_'; + } + uint8_t standard[48], urlsafe[48]; + size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard)); + size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe)); + EXPECT_EQ(len_standard, len_url); + EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0); +} + +// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2) +TEST(Base64, Rfc4648Vectors) { + const struct { + const char *plain; + const char *encoded; + } vectors[] = { + {"", ""}, + {"f", "Zg=="}, + {"fo", "Zm8="}, + {"foo", "Zm9v"}, + {"foob", "Zm9vYg=="}, + {"fooba", "Zm9vYmE="}, + {"foobar", "Zm9vYmFy"}, + }; + for (const auto &v : vectors) { + const auto *plain = reinterpret_cast(v.plain); + std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(encoded, v.encoded); + uint8_t buf[8]; + size_t len = base64_decode(reinterpret_cast(v.encoded), strlen(v.encoded), buf, sizeof(buf)); + EXPECT_EQ(len, strlen(v.plain)); + EXPECT_EQ(memcmp(buf, v.plain, len), 0); + } +} + } // namespace esphome::core::testing From 96e26c6a5f4d7eed5cae1a46eaa1d23b348b5c36 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:23 -0500 Subject: [PATCH 105/470] Bump bundled esphome-device-builder to 1.11.1 (#18475) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23b2f690..b78d183e02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 RUN \ platformio settings set enable_telemetry No \ From 7be4566b411d96f7a103879c80f38c9248ec97a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:33:13 -0500 Subject: [PATCH 106/470] Bump platformdirs from 4.11.2 to 4.11.3 (#18468) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4b1708637d..a986646230 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.2 # native esp-idf toolchain global cache dir +platformdirs==4.11.3 # native esp-idf toolchain global cache dir filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From a347a2e8793243dbaa5ffcc4b07fe3481abbe252 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 18:58:11 -0500 Subject: [PATCH 107/470] [api] Bump noise-c to 0.1.19 (#18473) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5ca9484336..cdc0d97c49 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.18") + cg.add_library("esphome/noise-c", "0.1.19") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 2c22523be5..39600d622a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.18 ; used by api + esphome/noise-c@0.1.19 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 15a626bcf34cc6905bb5c972dcf74475a86af691 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:38 -0500 Subject: [PATCH 108/470] Bump bundled esphome-device-builder to 1.11.2 (#18477) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b78d183e02..4a8daeaaf6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 RUN \ platformio settings set enable_telemetry No \ From 4416aacebb4b991a3c8916efffa21a62fa42cf70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:09:29 -0500 Subject: [PATCH 109/470] [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b80a394eec..8d00dbede2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -45,6 +45,11 @@ namespace esphome::socket { static const char *const TAG = "socket"; +#ifdef USE_ESP8266 +// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. +static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +#endif + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { +#ifdef USE_ESP8266 + // Would block: yield to SYS so queued WiFi RX reaches lwip and this read + // may succeed. Without this, inbound segments can sit unprocessed for + // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + if (this->waiting_for_data_()) { + optimistic_yield(ESP8266_YIELD_INTERVAL_US); + } +#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // No ESP8266 SYS yield here: only read() needs it today. If a consumer + // switches to scatter-gather reads, mirror the yield from read(). // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() { } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); - if (err == ERR_ABRT) { - // sometimes lwip returns ERR_ABRT for no apparent reason - // the connection works fine afterwards, and back with ESPAsyncTCP we - // indirectly also ignored this error - // FIXME: figure out where this is returned and what it means in this context - LWIP_LOG(" -> err ERR_ABRT"); - return 0; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = ECONNRESET; - return -1; + // ERR_ABRT: sometimes lwip returns it for no apparent reason; the + // connection works fine afterwards, and back with ESPAsyncTCP we + // indirectly also ignored this error, so treat it as success for + // flush purposes too. + // FIXME: figure out where this is returned and what it means in this context + if (err != ERR_ABRT) { + errno = ECONNRESET; + return -1; + } } +#ifdef USE_ESP8266 + // Flushed: yield to SYS so the queued segments reach the WiFi driver + // instead of waiting seconds for an unrelated SYS slot. Callers only get + // here after a successful tcp_write, so idle paths never yield. + optimistic_yield(ESP8266_YIELD_INTERVAL_US); +#endif return 0; } From 463e3833dae23329ad484c1a549dab13c2de7541 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:48 +1200 Subject: [PATCH 110/470] [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) --- .../beken_spi_led_strip/led_strip.cpp | 75 ++------- .../beken_spi_led_strip/led_strip.h | 23 +-- .../components/beken_spi_led_strip/light.py | 41 +++-- esphome/components/const/__init__.py | 2 + .../esp32_rmt_led_strip/led_strip.cpp | 86 ++--------- .../esp32_rmt_led_strip/led_strip.h | 29 +--- .../components/esp32_rmt_led_strip/light.py | 65 ++------ esphome/components/light/__init__.py | 106 +++++++++++++ esphome/components/light/channel_colors.h | 41 +++++ esphome/components/light/types.py | 3 + .../rp2040_pio_led_strip/led_strip.cpp | 59 ++----- .../rp2040_pio_led_strip/led_strip.h | 42 +---- .../components/rp2040_pio_led_strip/light.py | 33 ++-- .../common-ard-esp32_rmt_led_strip.yaml | 2 +- .../common-idf-esp32_rmt_led_strip.yaml | 2 +- .../beken_spi_led_strip/test.bk72xx-ard.yaml | 2 +- .../validate-legacy.bk72xx-ard.yaml | 10 ++ tests/components/e131/common-ard.yaml | 2 +- tests/components/e131/common-idf.yaml | 2 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- .../esp32_rmt_led_strip/common.yaml | 4 +- .../test.esp32-s3-idf.yaml | 4 +- .../validate-legacy.esp32-idf.yaml | 23 +++ tests/components/partition/common-ard.yaml | 2 +- tests/components/partition/common-idf.yaml | 2 +- .../rp2040_pio_led_strip/common.yaml | 4 +- .../validate-legacy.rp2040-ard.yaml | 18 +++ tests/components/wled/test.esp32-ard.yaml | 2 +- .../components/light/test_channel_colors.py | 144 ++++++++++++++++++ .../components/test_esp32_rmt_led_strip.py | 57 ------- 30 files changed, 454 insertions(+), 433 deletions(-) create mode 100644 esphome/components/light/channel_colors.h create mode 100644 tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml create mode 100644 tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml create mode 100644 tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml create mode 100644 tests/unit_tests/components/light/test_channel_colors.py delete mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 909634e266..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..2be5842818 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3ba89d2838..10710c8d29 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" +CONF_CHANNEL_COLORS = "channel_colors" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_IS_WRGB = "is_wrgb" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 95391ef100..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - - return {this->buf_ + (index * multiplier) + r + (white <= r), - this->buf_ + (index * multiplier) + g + (white <= g), - this->buf_ + (index * multiplier) + b + (white <= b), - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } - if (this->is_rgbw_ || this->is_wrgb_) { - char rgbw_order[5]; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - uint8_t rgb_index = 0; - for (uint8_t i = 0; i < 4; i++) { - rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; - } - rgbw_order[4] = '\0'; - ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); - } else { - ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 3e31309bff..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,15 +16,6 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } - void set_rgbw_order(uint8_t white_index) { - this->is_rgbw_ = true; - this->is_wrgb_ = false; - this->white_index_ = white_index; - } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; - // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. - uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2722a9b656..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +21,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -62,8 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" -CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" -def _validate_rgbw_order(value: str) -> str: - value = cv.string(value).upper() - if len(value) != 4 or set(value) != set("RGBW"): - raise cv.Invalid("RGBW order must be a permutation of RGBW") - return value - - -def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: - return rgbw_order.replace("W", ""), rgbw_order.index("W") - - -def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: - if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): - raise cv.Invalid( - f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " - f"'{CONF_IS_WRGB}'" - ) - return config - - CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), - cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), - cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), - _validate_rgbw_order_exclusivity, + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -198,14 +164,9 @@ async def to_code(config): ) ) - if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: - rgb_order, white_index = _split_rgbw_order(rgbw_order) - cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) - cg.add(var.set_rgbw_order(white_index)) - else: - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index b5b3d7c905..175f5b43cf 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +26,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +36,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -61,6 +66,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +77,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,6 +173,104 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d1..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index cf7041931e..1f4bea9ecd 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->buf_, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ ? 4 : 3; - return {this->buf_ + (index * multiplier) + r, - this->buf_ + (index * multiplier) + g, - this->buf_ + (index * multiplier) + b, - this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } void RP2040PIOLEDStripLightOutput::dump_config() { + char channel_colors[5]; ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:\n" " Pin: GPIO%d\n" " Number of LEDs: %d\n" - " RGBW: %s\n" - " RGB Order: %s\n" + " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_), - this->max_refresh_rate_); + this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index c499f0a7ca..b2162f641d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include @@ -18,15 +19,6 @@ namespace esphome::rp2040_pio_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - enum Chipset : uint8_t { CHIPSET_WS2812, CHIPSET_WS2812B, @@ -36,25 +28,6 @@ enum Chipset : uint8_t { CHIPSET_CUSTOM = 0xFF, }; -inline const char *rgb_order_to_string(RGBOrder order) { - switch (order) { - case ORDER_RGB: - return "RGB"; - case ORDER_RBG: - return "RBG"; - case ORDER_GRB: - return "GRB"; - case ORDER_GBR: - return "GBR"; - case ORDER_BGR: - return "BGR"; - case ORDER_BRG: - return "BRG"; - default: - return "UNKNOWN"; - } -} - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); + this->channel_colors_.has_white() + ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void clear_effect_data() override { for (int i = 0; i < this->size(); i++) { this->effect_data_[i] = 0; @@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } static void dma_write_complete_handler(); @@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint32_t num_leds_; - bool is_rgbw_; pio_hw_t *pio_; uint sm_; uint dma_chan_; dma_channel_config dma_config_; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; uint32_t last_refresh_{0}; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index b3f816102a..9f7479edd0 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import light, rp2 +from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType from esphome.util import _LOGGER @@ -37,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): +def generate_assembly_code(id, t0h, t0l, t1h, t1l): """ Generate assembly code with the given timing values. """ @@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") - Chipset = rp2040_pio_led_strip_ns.enum("Chipset") CHIPSETS = { @@ -159,15 +159,6 @@ class LEDStripTimings: T1L: int -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - CHIPSET_TIMINGS = { "WS2812": LEDStripTimings(20, 40, 46, 34), "WS2812B": LEDStripTimings(23, 49, 46, 26), @@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="rp2040_pio_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) id = config[CONF_ID].id await light.register_light(var, config) @@ -234,8 +230,9 @@ async def to_code(config): cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_pio(config[CONF_PIO])) cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) @@ -255,7 +252,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], CHIPSET_TIMINGS[chipset].T0H, CHIPSET_TIMINGS[chipset].T0L, CHIPSET_TIMINGS[chipset].T1H, @@ -270,7 +266,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], time_to_cycles(config[CONF_BIT0_HIGH]), time_to_cycles(config[CONF_BIT0_LOW]), time_to_cycles(config[CONF_BIT1_HIGH]), diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml index 15409caeaf..2bb831848c 100644 --- a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml +++ b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml @@ -1,6 +1,6 @@ light: - platform: beken_spi_led_strip - rgb_order: GRB + channel_colors: GRB pin: P16 num_leds: 30 chipset: ws2812 diff --git a/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml new file mode 100644 index 0000000000..3ca78398c3 --- /dev/null +++ b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml @@ -0,0 +1,10 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only, and only one strip because P16 is the sole supported pin. +light: + - platform: beken_spi_led_strip + name: Legacy RGBW + pin: P16 + num_leds: 30 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/e131/common-ard.yaml b/tests/components/e131/common-ard.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-ard.yaml +++ b/tests/components/e131/common-ard.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/common-idf.yaml b/tests/components/e131/common-idf.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-idf.yaml +++ b/tests/components/e131/common-idf.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..89255e2d87 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -6,7 +6,7 @@ light: pin: 2 pio: 0 num_leds: 256 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 effects: - e131: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 701e513ebd..7f52d32229 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -3,13 +3,13 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgbw_order: RWGB + channel_colors: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 6bf0639a52..132966eddf 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -8,14 +8,14 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + channel_colors: RGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml new file mode 100644 index 0000000000..6dd1bcdad3 --- /dev/null +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -0,0 +1,23 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: esp32_rmt_led_strip + id: legacy_rgb + pin: GPIO13 + num_leds: 60 + chipset: ws2812 + rgb_order: GRB # -> GRB + - platform: esp32_rmt_led_strip + id: legacy_rgbw + pin: GPIO14 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW + - platform: esp32_rmt_led_strip + id: legacy_wrgb + pin: GPIO15 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_wrgb: true # -> WGRB diff --git a/tests/components/partition/common-ard.yaml b/tests/components/partition/common-ard.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-ard.yaml +++ b/tests/components/partition/common-ard.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/common-idf.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/common-idf.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..1cb5fe0737 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -4,14 +4,14 @@ light: pin: 4 num_leds: 60 pio: 0 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 - platform: rp2040_pio_led_strip id: led_strip_custom_timings pin: 5 num_leds: 60 pio: 1 - rgb_order: GRB + channel_colors: GRB bit0_high: .1us bit0_low: 1.2us bit1_high: .69us diff --git a/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml new file mode 100644 index 0000000000..2ab124393b --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: rp2040_pio_led_strip + id: legacy_rgb + pin: 4 + num_leds: 60 + pio: 0 + chipset: WS2812 + rgb_order: GRB # -> GRB + - platform: rp2040_pio_led_strip + id: legacy_rgbw + pin: 5 + num_leds: 60 + pio: 1 + chipset: SK6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/wled/test.esp32-ard.yaml b/tests/components/wled/test.esp32-ard.yaml index 156b31181e..ecab767812 100644 --- a/tests/components/wled/test.esp32-ard.yaml +++ b/tests/components/wled/test.esp32-ard.yaml @@ -9,7 +9,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: 2 effects: diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py new file mode 100644 index 0000000000..0c129a8bb2 --- /dev/null +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -0,0 +1,144 @@ +"""Tests for the shared addressable-strip channel order helpers.""" + +import logging + +import pytest + +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.components.light import ( + channel_colors_struct, + migrate_channel_colors, + validate_channel_colors, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER +from esphome.types import ConfigType + +NO_WHITE = "light::ChannelColors::NO_WHITE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", "RGB"), + ("grb", "GRB"), + ("BRG", "BRG"), + ("rgbw", "RGBW"), + ("WRGB", "WRGB"), + ("GWRB", "GWRB"), + ], +) +def test_validate_channel_colors(value: str, expected: str) -> None: + assert validate_channel_colors(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "RG", # missing a channel + "RGBB", # duplicate channel + "RRGB", # duplicate channel, correct length + "RGBWW", # two white channels + "RGBX", # unknown channel + "RGBWX", # unknown channel, correct length + "", + ], +) +def test_validate_channel_colors_rejects_invalid(value: str) -> None: + with pytest.raises(cv.Invalid, match="is not a valid channel order"): + validate_channel_colors(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", (0, 1, 2, NO_WHITE)), + ("GRB", (1, 0, 2, NO_WHITE)), + ("BRG", (1, 2, 0, NO_WHITE)), + ("RGBW", (0, 1, 2, 3)), + ("GRBW", (1, 0, 2, 3)), + ("WRGB", (1, 2, 3, 0)), + ("GWRB", (2, 0, 3, 1)), + ], +) +def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: + struct = channel_colors_struct(value) + assert str(struct.base) == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.args.values()) == tuple( + str(field) for field in expected + ) + + +def _migrate(config: ConfigType) -> ConfigType: + return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config) + + +def test_migrate_passes_through_channel_colors() -> None: + config = {CONF_CHANNEL_COLORS: "GRBW"} + assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"} + + +@pytest.mark.parametrize( + ("deprecated", "expected", "named"), + [ + ({}, "GRB", "'rgb_order' is"), + ( + {CONF_IS_RGBW: False, CONF_IS_WRGB: False}, + "GRB", + "'rgb_order', 'is_rgbw' and 'is_wrgb' are", + ), + ({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"), + ({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"), + ], +) +def test_migrate_folds_deprecated_keys( + deprecated: ConfigType, + expected: str, + named: str, + caplog: pytest.LogCaptureFixture, +) -> None: + config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated} + with caplog.at_level(logging.WARNING): + result = _migrate(config) + + assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1} + assert f"[test_strip] {named} deprecated" in caplog.text + assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text + assert "2027.3.0" in caplog.text + + +def test_migrate_does_not_mutate_input() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + _migrate(config) + assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + + +@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB]) +def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None: + config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"} + with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"): + _migrate(config) + + +def test_migrate_reports_every_conflicting_key() -> None: + config = { + CONF_CHANNEL_COLORS: "GRBW", + CONF_RGB_ORDER: "GRB", + CONF_IS_RGBW: True, + CONF_IS_WRGB: False, + } + with pytest.raises( + cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'" + ): + _migrate(config) + + +def test_migrate_requires_channel_colors() -> None: + with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"): + _migrate({"num_leds": 1}) + + +def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True} + with pytest.raises(cv.Invalid, match="cannot both be enabled"): + _migrate(config) diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py deleted file mode 100644 index e2cb513e3b..0000000000 --- a/tests/unit_tests/components/test_esp32_rmt_led_strip.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from esphome.components.esp32_rmt_led_strip.light import ( - CONF_IS_WRGB, - CONF_RGBW_ORDER, - _split_rgbw_order, - _validate_rgbw_order, - _validate_rgbw_order_exclusivity, -) -import esphome.config_validation as cv -from esphome.const import CONF_IS_RGBW - - -def test_validate_rgbw_order() -> None: - assert _validate_rgbw_order("rwgb") == "RWGB" - - -@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) -def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: - with pytest.raises(cv.Invalid, match="permutation of RGBW"): - _validate_rgbw_order(rgbw_order) - - -@pytest.mark.parametrize( - ("rgbw_order", "expected"), - [ - ("WRGB", ("RGB", 0)), - ("RWGB", ("RGB", 1)), - ("GWRB", ("GRB", 1)), - ("RGBW", ("RGB", 3)), - ], -) -def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: - assert _split_rgbw_order(rgbw_order) == expected - - -@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: - with pytest.raises(cv.Invalid, match="cannot be used with"): - _validate_rgbw_order_exclusivity( - { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: conflict == CONF_IS_RGBW, - CONF_IS_WRGB: conflict == CONF_IS_WRGB, - } - ) - - -@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: - config = { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: False, - CONF_IS_WRGB: False, - } - config[legacy_option] = False - assert _validate_rgbw_order_exclusivity(config) is config From fffa902a1a78ac0daa6c759de0f20611e4846fbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH 111/470] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + ) From 096e71bd678ff5707ddbd013fe59c012e3abc8f9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:14 -0500 Subject: [PATCH 112/470] Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85a0f55263..683008400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 3a403c40d5f7d02dcf6bfb8eca6d614471fa3b91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:57:12 -0700 Subject: [PATCH 113/470] [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) --- esphome/components/ld2420/ld2420.cpp | 2 -- esphome/components/ld2420/ld2420.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); From b9041566eaee70079271526dc3104360a78d067c Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:00 -0500 Subject: [PATCH 114/470] Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 683008400a..080a437147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.2 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 014cc199021325153f0572c17f85386760e5ae09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 12:00:39 -0700 Subject: [PATCH 115/470] [api] Bump noise-c to 0.1.18 (#18451) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..5ca9484336 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + cg.add_library("esphome/noise-c", "0.1.18") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..2c22523be5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 4ce6d59484be6fb55b1bf7cd4d0bdea4785ee1d1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:23 -0500 Subject: [PATCH 116/470] Bump bundled esphome-device-builder to 1.11.1 (#18475) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23b2f690..b78d183e02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 RUN \ platformio settings set enable_telemetry No \ From 1fd63372545525bfa8cc48e781fb96101b37e3f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 18:58:11 -0500 Subject: [PATCH 117/470] [api] Bump noise-c to 0.1.19 (#18473) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5ca9484336..cdc0d97c49 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.18") + cg.add_library("esphome/noise-c", "0.1.19") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 2c22523be5..39600d622a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.18 ; used by api + esphome/noise-c@0.1.19 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 4dea147386d4d309e84ec3eee96e6169a224cf40 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:38 -0500 Subject: [PATCH 118/470] Bump bundled esphome-device-builder to 1.11.2 (#18477) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b78d183e02..4a8daeaaf6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 RUN \ platformio settings set enable_telemetry No \ From 482869fbbe04a8ff28746f066e90ee7d57bbe81e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:09:29 -0500 Subject: [PATCH 119/470] [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..098056d499 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -45,6 +45,11 @@ namespace esphome::socket { static const char *const TAG = "socket.lwip"; +#ifdef USE_ESP8266 +// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. +static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +#endif + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { +#ifdef USE_ESP8266 + // Would block: yield to SYS so queued WiFi RX reaches lwip and this read + // may succeed. Without this, inbound segments can sit unprocessed for + // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + if (this->waiting_for_data_()) { + optimistic_yield(ESP8266_YIELD_INTERVAL_US); + } +#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // No ESP8266 SYS yield here: only read() needs it today. If a consumer + // switches to scatter-gather reads, mirror the yield from read(). // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() { } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); - if (err == ERR_ABRT) { - // sometimes lwip returns ERR_ABRT for no apparent reason - // the connection works fine afterwards, and back with ESPAsyncTCP we - // indirectly also ignored this error - // FIXME: figure out where this is returned and what it means in this context - LWIP_LOG(" -> err ERR_ABRT"); - return 0; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = ECONNRESET; - return -1; + // ERR_ABRT: sometimes lwip returns it for no apparent reason; the + // connection works fine afterwards, and back with ESPAsyncTCP we + // indirectly also ignored this error, so treat it as success for + // flush purposes too. + // FIXME: figure out where this is returned and what it means in this context + if (err != ERR_ABRT) { + errno = ECONNRESET; + return -1; + } } +#ifdef USE_ESP8266 + // Flushed: yield to SYS so the queued segments reach the WiFi driver + // instead of waiting seconds for an unrelated SYS slot. Callers only get + // here after a successful tcp_write, so idle paths never yield. + optimistic_yield(ESP8266_YIELD_INTERVAL_US); +#endif return 0; } From 6a247dfe912477e516f8da6f13e4ab002544a44e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:48 +1200 Subject: [PATCH 120/470] [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) --- .../beken_spi_led_strip/led_strip.cpp | 75 ++------- .../beken_spi_led_strip/led_strip.h | 23 +-- .../components/beken_spi_led_strip/light.py | 41 +++-- esphome/components/const/__init__.py | 2 + .../esp32_rmt_led_strip/led_strip.cpp | 86 ++--------- .../esp32_rmt_led_strip/led_strip.h | 29 +--- .../components/esp32_rmt_led_strip/light.py | 65 ++------ esphome/components/light/__init__.py | 108 ++++++++++++- esphome/components/light/channel_colors.h | 41 +++++ esphome/components/light/types.py | 3 + .../rp2040_pio_led_strip/led_strip.cpp | 59 ++----- .../rp2040_pio_led_strip/led_strip.h | 42 +---- .../components/rp2040_pio_led_strip/light.py | 33 ++-- .../common-ard-esp32_rmt_led_strip.yaml | 2 +- .../common-idf-esp32_rmt_led_strip.yaml | 2 +- .../beken_spi_led_strip/test.bk72xx-ard.yaml | 2 +- .../validate-legacy.bk72xx-ard.yaml | 10 ++ tests/components/e131/common-ard.yaml | 2 +- tests/components/e131/common-idf.yaml | 2 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- .../esp32_rmt_led_strip/common.yaml | 4 +- .../test.esp32-s3-idf.yaml | 4 +- .../validate-legacy.esp32-idf.yaml | 23 +++ tests/components/partition/common-ard.yaml | 2 +- tests/components/partition/common-idf.yaml | 2 +- .../rp2040_pio_led_strip/common.yaml | 4 +- .../validate-legacy.rp2040-ard.yaml | 18 +++ tests/components/wled/test.esp32-ard.yaml | 2 +- .../components/light/test_channel_colors.py | 144 ++++++++++++++++++ .../components/test_esp32_rmt_led_strip.py | 57 ------- 30 files changed, 455 insertions(+), 434 deletions(-) create mode 100644 esphome/components/light/channel_colors.h create mode 100644 tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml create mode 100644 tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml create mode 100644 tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml create mode 100644 tests/unit_tests/components/light/test_channel_colors.py delete mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 909634e266..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..2be5842818 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..956a5490e3 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" +CONF_CHANNEL_COLORS = "channel_colors" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_IS_WRGB = "is_wrgb" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 95391ef100..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - - return {this->buf_ + (index * multiplier) + r + (white <= r), - this->buf_ + (index * multiplier) + g + (white <= g), - this->buf_ + (index * multiplier) + b + (white <= b), - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } - if (this->is_rgbw_ || this->is_wrgb_) { - char rgbw_order[5]; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - uint8_t rgb_index = 0; - for (uint8_t i = 0; i < 4; i++) { - rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; - } - rgbw_order[4] = '\0'; - ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); - } else { - ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 3e31309bff..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,15 +16,6 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } - void set_rgbw_order(uint8_t white_index) { - this->is_rgbw_ = true; - this->is_wrgb_ = false; - this->white_index_ = white_index; - } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; - // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. - uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2722a9b656..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +21,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -62,8 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" -CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" -def _validate_rgbw_order(value: str) -> str: - value = cv.string(value).upper() - if len(value) != 4 or set(value) != set("RGBW"): - raise cv.Invalid("RGBW order must be a permutation of RGBW") - return value - - -def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: - return rgbw_order.replace("W", ""), rgbw_order.index("W") - - -def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: - if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): - raise cv.Invalid( - f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " - f"'{CONF_IS_WRGB}'" - ) - return config - - CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), - cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), - cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), - _validate_rgbw_order_exclusivity, + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -198,14 +164,9 @@ async def to_code(config): ) ) - if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: - rgb_order, white_index = _split_rgbw_order(rgbw_order) - cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) - cg.add(var.set_rgbw_order(white_index)) - else: - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7c4d7ed431..f3a859e38c 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +26,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +36,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -61,6 +66,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +77,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,7 +173,105 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d1..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index cf7041931e..1f4bea9ecd 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->buf_, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ ? 4 : 3; - return {this->buf_ + (index * multiplier) + r, - this->buf_ + (index * multiplier) + g, - this->buf_ + (index * multiplier) + b, - this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } void RP2040PIOLEDStripLightOutput::dump_config() { + char channel_colors[5]; ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:\n" " Pin: GPIO%d\n" " Number of LEDs: %d\n" - " RGBW: %s\n" - " RGB Order: %s\n" + " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_), - this->max_refresh_rate_); + this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index c499f0a7ca..b2162f641d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include @@ -18,15 +19,6 @@ namespace esphome::rp2040_pio_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - enum Chipset : uint8_t { CHIPSET_WS2812, CHIPSET_WS2812B, @@ -36,25 +28,6 @@ enum Chipset : uint8_t { CHIPSET_CUSTOM = 0xFF, }; -inline const char *rgb_order_to_string(RGBOrder order) { - switch (order) { - case ORDER_RGB: - return "RGB"; - case ORDER_RBG: - return "RBG"; - case ORDER_GRB: - return "GRB"; - case ORDER_GBR: - return "GBR"; - case ORDER_BGR: - return "BGR"; - case ORDER_BRG: - return "BRG"; - default: - return "UNKNOWN"; - } -} - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); + this->channel_colors_.has_white() + ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void clear_effect_data() override { for (int i = 0; i < this->size(); i++) { this->effect_data_[i] = 0; @@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } static void dma_write_complete_handler(); @@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint32_t num_leds_; - bool is_rgbw_; pio_hw_t *pio_; uint sm_; uint dma_chan_; dma_channel_config dma_config_; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; uint32_t last_refresh_{0}; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index b3f816102a..9f7479edd0 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import light, rp2 +from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType from esphome.util import _LOGGER @@ -37,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): +def generate_assembly_code(id, t0h, t0l, t1h, t1l): """ Generate assembly code with the given timing values. """ @@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") - Chipset = rp2040_pio_led_strip_ns.enum("Chipset") CHIPSETS = { @@ -159,15 +159,6 @@ class LEDStripTimings: T1L: int -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - CHIPSET_TIMINGS = { "WS2812": LEDStripTimings(20, 40, 46, 34), "WS2812B": LEDStripTimings(23, 49, 46, 26), @@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="rp2040_pio_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) id = config[CONF_ID].id await light.register_light(var, config) @@ -234,8 +230,9 @@ async def to_code(config): cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_pio(config[CONF_PIO])) cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) @@ -255,7 +252,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], CHIPSET_TIMINGS[chipset].T0H, CHIPSET_TIMINGS[chipset].T0L, CHIPSET_TIMINGS[chipset].T1H, @@ -270,7 +266,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], time_to_cycles(config[CONF_BIT0_HIGH]), time_to_cycles(config[CONF_BIT0_LOW]), time_to_cycles(config[CONF_BIT1_HIGH]), diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml index 15409caeaf..2bb831848c 100644 --- a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml +++ b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml @@ -1,6 +1,6 @@ light: - platform: beken_spi_led_strip - rgb_order: GRB + channel_colors: GRB pin: P16 num_leds: 30 chipset: ws2812 diff --git a/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml new file mode 100644 index 0000000000..3ca78398c3 --- /dev/null +++ b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml @@ -0,0 +1,10 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only, and only one strip because P16 is the sole supported pin. +light: + - platform: beken_spi_led_strip + name: Legacy RGBW + pin: P16 + num_leds: 30 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/e131/common-ard.yaml b/tests/components/e131/common-ard.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-ard.yaml +++ b/tests/components/e131/common-ard.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/common-idf.yaml b/tests/components/e131/common-idf.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-idf.yaml +++ b/tests/components/e131/common-idf.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..89255e2d87 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -6,7 +6,7 @@ light: pin: 2 pio: 0 num_leds: 256 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 effects: - e131: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 701e513ebd..7f52d32229 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -3,13 +3,13 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgbw_order: RWGB + channel_colors: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 6bf0639a52..132966eddf 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -8,14 +8,14 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + channel_colors: RGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml new file mode 100644 index 0000000000..6dd1bcdad3 --- /dev/null +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -0,0 +1,23 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: esp32_rmt_led_strip + id: legacy_rgb + pin: GPIO13 + num_leds: 60 + chipset: ws2812 + rgb_order: GRB # -> GRB + - platform: esp32_rmt_led_strip + id: legacy_rgbw + pin: GPIO14 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW + - platform: esp32_rmt_led_strip + id: legacy_wrgb + pin: GPIO15 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_wrgb: true # -> WGRB diff --git a/tests/components/partition/common-ard.yaml b/tests/components/partition/common-ard.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-ard.yaml +++ b/tests/components/partition/common-ard.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/common-idf.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/common-idf.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..1cb5fe0737 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -4,14 +4,14 @@ light: pin: 4 num_leds: 60 pio: 0 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 - platform: rp2040_pio_led_strip id: led_strip_custom_timings pin: 5 num_leds: 60 pio: 1 - rgb_order: GRB + channel_colors: GRB bit0_high: .1us bit0_low: 1.2us bit1_high: .69us diff --git a/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml new file mode 100644 index 0000000000..2ab124393b --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: rp2040_pio_led_strip + id: legacy_rgb + pin: 4 + num_leds: 60 + pio: 0 + chipset: WS2812 + rgb_order: GRB # -> GRB + - platform: rp2040_pio_led_strip + id: legacy_rgbw + pin: 5 + num_leds: 60 + pio: 1 + chipset: SK6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/wled/test.esp32-ard.yaml b/tests/components/wled/test.esp32-ard.yaml index 156b31181e..ecab767812 100644 --- a/tests/components/wled/test.esp32-ard.yaml +++ b/tests/components/wled/test.esp32-ard.yaml @@ -9,7 +9,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: 2 effects: diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py new file mode 100644 index 0000000000..0c129a8bb2 --- /dev/null +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -0,0 +1,144 @@ +"""Tests for the shared addressable-strip channel order helpers.""" + +import logging + +import pytest + +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.components.light import ( + channel_colors_struct, + migrate_channel_colors, + validate_channel_colors, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER +from esphome.types import ConfigType + +NO_WHITE = "light::ChannelColors::NO_WHITE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", "RGB"), + ("grb", "GRB"), + ("BRG", "BRG"), + ("rgbw", "RGBW"), + ("WRGB", "WRGB"), + ("GWRB", "GWRB"), + ], +) +def test_validate_channel_colors(value: str, expected: str) -> None: + assert validate_channel_colors(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "RG", # missing a channel + "RGBB", # duplicate channel + "RRGB", # duplicate channel, correct length + "RGBWW", # two white channels + "RGBX", # unknown channel + "RGBWX", # unknown channel, correct length + "", + ], +) +def test_validate_channel_colors_rejects_invalid(value: str) -> None: + with pytest.raises(cv.Invalid, match="is not a valid channel order"): + validate_channel_colors(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", (0, 1, 2, NO_WHITE)), + ("GRB", (1, 0, 2, NO_WHITE)), + ("BRG", (1, 2, 0, NO_WHITE)), + ("RGBW", (0, 1, 2, 3)), + ("GRBW", (1, 0, 2, 3)), + ("WRGB", (1, 2, 3, 0)), + ("GWRB", (2, 0, 3, 1)), + ], +) +def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: + struct = channel_colors_struct(value) + assert str(struct.base) == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.args.values()) == tuple( + str(field) for field in expected + ) + + +def _migrate(config: ConfigType) -> ConfigType: + return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config) + + +def test_migrate_passes_through_channel_colors() -> None: + config = {CONF_CHANNEL_COLORS: "GRBW"} + assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"} + + +@pytest.mark.parametrize( + ("deprecated", "expected", "named"), + [ + ({}, "GRB", "'rgb_order' is"), + ( + {CONF_IS_RGBW: False, CONF_IS_WRGB: False}, + "GRB", + "'rgb_order', 'is_rgbw' and 'is_wrgb' are", + ), + ({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"), + ({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"), + ], +) +def test_migrate_folds_deprecated_keys( + deprecated: ConfigType, + expected: str, + named: str, + caplog: pytest.LogCaptureFixture, +) -> None: + config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated} + with caplog.at_level(logging.WARNING): + result = _migrate(config) + + assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1} + assert f"[test_strip] {named} deprecated" in caplog.text + assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text + assert "2027.3.0" in caplog.text + + +def test_migrate_does_not_mutate_input() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + _migrate(config) + assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + + +@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB]) +def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None: + config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"} + with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"): + _migrate(config) + + +def test_migrate_reports_every_conflicting_key() -> None: + config = { + CONF_CHANNEL_COLORS: "GRBW", + CONF_RGB_ORDER: "GRB", + CONF_IS_RGBW: True, + CONF_IS_WRGB: False, + } + with pytest.raises( + cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'" + ): + _migrate(config) + + +def test_migrate_requires_channel_colors() -> None: + with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"): + _migrate({"num_leds": 1}) + + +def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True} + with pytest.raises(cv.Invalid, match="cannot both be enabled"): + _migrate(config) diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py deleted file mode 100644 index e2cb513e3b..0000000000 --- a/tests/unit_tests/components/test_esp32_rmt_led_strip.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from esphome.components.esp32_rmt_led_strip.light import ( - CONF_IS_WRGB, - CONF_RGBW_ORDER, - _split_rgbw_order, - _validate_rgbw_order, - _validate_rgbw_order_exclusivity, -) -import esphome.config_validation as cv -from esphome.const import CONF_IS_RGBW - - -def test_validate_rgbw_order() -> None: - assert _validate_rgbw_order("rwgb") == "RWGB" - - -@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) -def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: - with pytest.raises(cv.Invalid, match="permutation of RGBW"): - _validate_rgbw_order(rgbw_order) - - -@pytest.mark.parametrize( - ("rgbw_order", "expected"), - [ - ("WRGB", ("RGB", 0)), - ("RWGB", ("RGB", 1)), - ("GWRB", ("GRB", 1)), - ("RGBW", ("RGB", 3)), - ], -) -def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: - assert _split_rgbw_order(rgbw_order) == expected - - -@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: - with pytest.raises(cv.Invalid, match="cannot be used with"): - _validate_rgbw_order_exclusivity( - { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: conflict == CONF_IS_RGBW, - CONF_IS_WRGB: conflict == CONF_IS_WRGB, - } - ) - - -@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: - config = { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: False, - CONF_IS_WRGB: False, - } - config[legacy_option] = False - assert _validate_rgbw_order_exclusivity(config) is config From 8b888f31e0bf4d2dedd34fa25d7f1aa593a0c581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH 121/470] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + ) From d1391c2b10a2f473b11d2a69c0ea8e3eecd260c2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:04:49 +1200 Subject: [PATCH 122/470] Bump version to 2026.8.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 2df6d3ded0..3dad4629be 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.8.0b4 +PROJECT_NUMBER = 2026.8.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 73155e06ee..e86465f9a0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b4" +__version__ = "2026.8.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9823205ef3080e5e7fd9c4004f3cefc1d68a0e37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:14 -0500 Subject: [PATCH 123/470] [api] Bump noise-c to 0.1.20 (#18482) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index cdc0d97c49..3e69d5842c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.19") + cg.add_library("esphome/noise-c", "0.1.20") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 39600d622a..13bb5a556f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.19 ; used by api + esphome/noise-c@0.1.20 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From ae730d6357e6a82eed82f89a3e396793bf499baf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:33 -0500 Subject: [PATCH 124/470] [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) --- .github/workflows/ci.yml | 17 ++++++++++++++--- tests/benchmarks/components/api/__init__.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd1a382c21..0c81c783b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -464,10 +464,21 @@ jobs: - name: Build benchmarks id: build run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0d02e0b054..0565bc5330 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # components have hardware dependencies (BLE/UART/RMT); lightweight # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_ZWAVE_PROXY") From 476d540065ecd352a5aa4ff52179966d1f732163 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:22:28 -0400 Subject: [PATCH 125/470] [ci] Fall back to files API when PR diff exceeds GitHub line limit (#18486) --- script/helpers.py | 5 +++-- tests/script/test_helpers.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 11549808ff..8132ee49e5 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -558,8 +558,9 @@ def _get_changed_files_github_actions() -> list[str] | None: try: return _get_changed_files_from_command(cmd) except Exception as e: - # If it fails due to the 300 file limit, use the API method - if "maximum" in str(e) and "files" in str(e): + # If it fails due to a diff limit (300 files or 20000 lines), + # use the API method which only returns filenames + if "diff exceeded the maximum" in str(e): cmd = [ "gh", "api", diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index a07e56cea5..2c3ae95655 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -244,6 +244,44 @@ def test_get_changed_files_github_actions_pull_request_large_pr( assert result == expected_files +def test_get_changed_files_github_actions_pull_request_large_diff( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions fallback for PRs with >20000 diff lines.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="17909"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # First call fails with too many diff lines error, second succeeds with API method + mock_get.side_effect = [ + Exception( + "could not find pull request diff: HTTP 406: Sorry, " + "the diff exceeded the maximum number of lines (20000)" + ), + expected_files, + ] + + result = _get_changed_files_github_actions() + + assert mock_get.call_count == 2 + mock_get.assert_any_call(["gh", "pr", "diff", "17909", "--name-only"]) + mock_get.assert_any_call( + [ + "gh", + "api", + "repos/esphome/esphome/pulls/17909/files", + "--paginate", + "--jq", + ".[].filename", + ] + ) + assert result == expected_files + + def test_get_changed_files_github_actions_pull_request_other_error( monkeypatch: MonkeyPatch, ) -> None: From 92f55f721f35d36b4a883811c6cebb6b1027cb7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:32:37 -0500 Subject: [PATCH 126/470] [api] Bump noise-c to 0.1.21 (#18484) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3e69d5842c..912d580a0f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.20") + cg.add_library("esphome/noise-c", "0.1.21") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 13bb5a556f..4c372cc0bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.20 ; used by api + esphome/noise-c@0.1.21 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 285a508e09effe69510fbde25f92b6eb7dd21c03 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 18 Aug 2026 09:09:12 -0700 Subject: [PATCH 127/470] [modbus] CRC scan all unknown function codes (#18483) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/modbus/modbus.cpp | 33 ++-- esphome/components/modbus/modbus.h | 2 +- esphome/components/modbus/modbus_helpers.h | 32 ++++ tests/components/modbus/common.h | 36 +++++ .../modbus/modbus_unknown_function_test.cpp | 141 ++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/components/modbus/modbus_unknown_function_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5305f6313f..e4bd51ad5a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() { this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); } -uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { - // Custom functions could be any length - we have to rely on the CRC to determine completeness. +uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { + // Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values) + // could be any length - we have to rely on the CRC to determine completeness. // If a CRC match is never found, the buffer will eventually overflow and be cleared. const uint8_t *raw = &this->rx_buffer_[0]; const size_t size = this->rx_buffer_.size(); - for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { - if (crc16(raw, len) == 0) - return len; + const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); + if (min_length > max_len) + return 0; + // The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value, + // so we seed once over the first min_length bytes and extend one byte at a time instead of + // recomputing the whole prefix for every candidate length. + uint16_t crc = crc16(raw, min_length); + if (crc == 0) + return min_length; + for (uint16_t len = min_length; len < max_len; len++) { + crc = crc16(&raw[len], 1, crc); + if (crc == 0) + return len + 1; } return 0; } @@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; @@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index dfe4a4872d..bb303c43a8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. - uint16_t find_custom_frame_end_(uint16_t min_length) const; + uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index c737e206c0..b2454e6f14 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } +/// True for any function code whose frame length the parsers cannot predict - everything the +/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list +/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined +/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes +/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. +/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - +/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what +/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary +/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec +/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one +/// pays (recovery by timeout instead of an immediate CRC failure). +inline bool is_function_code_unknown_length(uint8_t function_code) { + switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + case FunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FIFO_QUEUE: + return false; + default: + return true; + } +} + // Returns the expected length of a server response PDU based on the function code. // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index d03ccf8ec3..e6c37b0e6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include #include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus::testing { @@ -30,4 +33,37 @@ class RecordingUART : public NullUART { std::vector written; }; +// A UART the test can inject received bytes into, so frames travel the full receive path +// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded. +class InjectableUART : public RecordingUART { + public: + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + + // Queues a complete wire frame: address + PDU + CRC16 (low byte first). + void inject_frame(uint8_t address, std::span pdu) { + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp new file mode 100644 index 0000000000..8b91d088b8 --- /dev/null +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records custom-response dispatches so tests can assert an unknown-length frame reached the device. +class CustomRecordingDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->statuses.push_back(status); + } + std::vector> requests; + std::vector> responses; + std::vector statuses; +}; + +// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test. +class SilentServerDevice : public ModbusServerDevice {}; + +// Drives full client frames through the server hub's receive path (same shape as the broadcast tests). +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser. + // Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span data) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(data.size() + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end()); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the +// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - +// must classify as unknown length. The exception flag masks off first. +TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { + for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + // Exception replies classify by their base code. + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); + EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); + // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. + for (int fc = 0; fc <= 0xFF; fc++) { + if (helpers::is_function_code_custom(fc)) + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; + } + EXPECT_FALSE(helpers::is_function_code_custom(0x49)); + + // Derived contract check: the helper must say "unknown" exactly when both length parsers fall + // through to default. With a zero-filled max-size PDU every explicit case returns at least 2 + // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The + // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() + // switches on the unmasked byte and server_pdu_length() early-returns the exception length. + for (int fc = 0; fc <= 0x7F; fc++) { + const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "client_pdu_length disagrees for fc 0x" << std::hex << fc; + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "server_pdu_length disagrees for fc 0x" << std::hex << fc; + } +} + +// A response with a function code outside the user-defined ranges (0x49) has no length case in +// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already +// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the +// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device. +TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) { + InjectableUART uart; + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + CustomRecordingDevice device(&hub, 0x02); + + const uint8_t request[] = {0x49, 0x01}; + ASSERT_TRUE(device.queue_pdu(request)); + hub.loop(); // transmit + ASSERT_FALSE(uart.written.empty()); + + const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB}; + uart.inject_frame(0x02, response_pdu); + hub.loop(); // receive + parse + match + dispatch + + ASSERT_EQ(device.responses.size(), 1u); + EXPECT_EQ(device.requests[0], std::vector(request, request + sizeof(request))); + EXPECT_EQ(device.responses[0], std::vector(response_pdu, response_pdu + sizeof(response_pdu))); + EXPECT_FALSE(device.statuses[0].has_value()); +} + +// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC +// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails +// to parse and the client gets silence instead of the exception. +TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + SilentServerDevice device; + device.set_address(0x02); + hub.register_device(&device); + + const uint8_t data[] = {0x02, 0xAA, 0xBB}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data)); + + // Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC. + std::vector expected = {0x02, 0xC9, 0x01}; + uint16_t crc = crc16(expected.data(), expected.size()); + expected.push_back(crc & 0xFF); + expected.push_back(crc >> 8); + EXPECT_EQ(uart.written, expected); +} + +} // namespace esphome::modbus::testing From 5c9d050ebe2ef415484e2c3dc1de61cb26f6b09a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:02:54 +0000 Subject: [PATCH 128/470] Bump aioesphomeapi from 45.10.3 to 45.11.0 (#18493) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a986646230..3d25440671 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.3 +aioesphomeapi==45.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 804e8fb856ce5a56a1dd1216f3f3e38273d8b4df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 15:44:38 -0500 Subject: [PATCH 129/470] [socket] Remove constant duplicated by the beta merge (#18496) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b20f79fba1..8d00dbede2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -50,11 +50,6 @@ static const char *const TAG = "socket"; static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; #endif -#ifdef USE_ESP8266 -// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. -static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; -#endif - // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) From 0a88c81d95897db3504aca4e623c8fe27daa9a76 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:23 -0500 Subject: [PATCH 130/470] Bump aioesphomeapi from 45.11.0 to 45.12.0 (#18501) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3d25440671..e4521859e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.11.0 +aioesphomeapi==45.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 7a999f9a48f89d9d6561ed5cd46853d5c64f9828 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 131/470] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6279a26dc4..ce14b0152a 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From 4f866c563b5721a1a9ed1225b6b4fe50ef4f2637 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:36:57 -0500 Subject: [PATCH 132/470] [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) --- esphome/platformio/ccache.py.script | 9 +- esphome/platformio/toolchain.py | 63 +++-- tests/unit_tests/test_platformio_toolchain.py | 240 +++++++++++++++++- 3 files changed, 286 insertions(+), 26 deletions(-) diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script index cc08a8c044..22592a2398 100644 --- a/esphome/platformio/ccache.py.script +++ b/esphome/platformio/ccache.py.script @@ -1,5 +1,4 @@ import os -import shutil # pylint: disable=E0602 Import("env") # noqa @@ -9,15 +8,17 @@ Import("env") # noqa # esphome/platformio/toolchain.py); this script only supplies the SCons-level # mechanism. # +# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has +# already stripped the Windows \\?\ prefix that cmd.exe cannot run. +# # This is a "pre" script, so the platform's builder (which sets CC/CXX and # clones the construction environment for framework and library builds) runs # after it. Replacing CC/CXX here would be overwritten, and replacing them in # a "post" script would miss the already-cloned library environments. Wrapping # SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler # invocation from every environment funnels through it at execution time. -if ( - os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" - and (ccache_path := shutil.which("ccache")) is not None +if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and ( + ccache_path := os.environ.get("ESPHOME_CCACHE_PATH") ): original_spawn = env["SPAWN"] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 08a4fcff78..d76581d032 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -60,6 +60,9 @@ def _strip_win_long_path_prefix(path: str) -> str: "The system cannot find the path specified." Stripping the prefix early keeps the path shell-quotable. + Also applied to the ccache path exported by ``_ccache_env()``, which + ``shutil.which`` can return with the same prefix. + No-op on non-Windows platforms. """ if sys.platform != "win32": @@ -235,8 +238,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_usable() -> bool: - """Return True when the ``ccache`` on PATH actually runs. +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. ``shutil.which`` proves existence, not runnability: on Windows it also matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose @@ -244,9 +247,6 @@ def _ccache_usable() -> bool: step with an opaque OS error, so probe once and fall back to compiling without ccache when the probe fails. """ - ccache = shutil.which("ccache") - if ccache is None: - return False try: subprocess.run( [ccache, "--version"], @@ -265,14 +265,29 @@ def _ccache_usable() -> bool: def _ccache_env() -> dict[str, str]: - """Return ccache settings for PlatformIO builds. + r"""Return ccache settings for PlatformIO builds. Enabled by default whenever the ``ccache`` binary is on PATH; set ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to - force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` - so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, - which wraps compiler invocations inside SCons) only have to check for - ``"1"`` instead of re-implementing the policy. + force it on without the runnability probe; a binary is still needed). + The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the + binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts + (the shared ``ccache.py`` extra script, which wraps compiler invocations + inside SCons) only have to check for ``"1"`` and use the path as given + instead of re-implementing the policy. + + The path is exported rather than looked up again inside SCons because + ``shutil.which`` can return a Windows extended-length ``\\?\`` path + (ESPHome Desktop puts its bundled ccache on PATH that way). Such a path + runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, + but SCons runs every compile through ``cmd.exe``, which fails on it with + "The system cannot find the path specified." (#18399), so the prefix is + stripped here with ``_strip_win_long_path_prefix()`` before the + runnability probe, which therefore validates the exact string the build + will execute. + ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the + script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this + function always sets both or neither. The returned values are merged into the environment of the PlatformIO subprocess only, never into ``os.environ``: a long-running process @@ -293,13 +308,27 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - if "ESPHOME_CCACHE_ENABLE" in os.environ: - enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") - else: - enabled = _ccache_usable() - env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} - if not enabled: - return env + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return {"ESPHOME_CCACHE_ENABLE": "0"} + ccache_path = shutil.which("ccache") + if ccache_path is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return {"ESPHOME_CCACHE_ENABLE": "0"} + # Strip before probing so the probe validates (and the failure warning + # names) the exact string the build will execute through cmd.exe. + ccache_path = _strip_win_long_path_prefix(ccache_path) + # An explicit opt-in skips the runnability probe. + if not explicit and not _ccache_runs(ccache_path): + return {"ESPHOME_CCACHE_ENABLE": "0"} + env = { + "ESPHOME_CCACHE_ENABLE": "1", + "ESPHOME_CCACHE_PATH": ccache_path, + } # build_path is set during preload for every config-loading command, so it # being unset means a caller built the environment too early; fail loudly # rather than with an opaque TypeError from Path(None). diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index eebb0b8cd7..172b288c25 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json @@ -437,6 +437,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert env["CCACHE_DIR"].endswith("platformio-ccache") assert env["CCACHE_NOHASHDIR"] == "true" @@ -446,17 +447,35 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ -def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: - """Ccache stays off when the binary is not on PATH.""" +@pytest.mark.parametrize( + ("env_vars", "expect_warning"), + [ + pytest.param({}, False, id="default"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"), + ], +) +def test_ccache_env_disabled_without_binary( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + env_vars: dict[str, str], + expect_warning: bool, +) -> None: + """Ccache stays off when the binary is not on PATH, even when forced on. + + A deliberate opt-in that finds no binary is downgraded with a warning so + the user can tell why it had no effect; the default path stays quiet. + """ CORE.build_path = setup_core / "build" / "test" with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env_vars, clear=True), patch.object(toolchain.shutil, "which", return_value=None), + caplog.at_level("WARNING"), ): env = toolchain._ccache_env() assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + assert ("no ccache binary is on PATH" in caplog.text) is expect_warning @pytest.mark.parametrize( @@ -489,14 +508,47 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), patch.object(toolchain.subprocess, "run") as mock_probe, ): env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + # The binary's location is still handed to the build script. + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" mock_probe.assert_not_called() +def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: + r"""A ``\\?\`` ccache path from PATH is exported without the prefix. + + That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``. + """ + CORE.build_path = setup_core / "build" / "test" + prefixed = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder" + "\\ccache\\ccache.exe" + ) + stripped = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=True), + # shutil.which is patched, so the win32 code path of the real + # implementation (which crashes on a POSIX host) is never reached. + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch.object(toolchain.shutil, "which", return_value=prefixed), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == stripped + # The probe validates the exact string the build will execute. + assert mock_probe.call_args[0][0] == [stripped, "--version"] + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -516,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -563,8 +615,10 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( env = mock_run_external_process.call_args[1]["env"] assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "ESPHOME_CCACHE_PATH" not in os.environ assert "CCACHE_BASEDIR" not in os.environ @@ -613,6 +667,182 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for ccache.py.""" + + def Replace(self, **kwargs: object) -> None: # noqa: N802 + self.update(kwargs) + + +def _load_ccache_script( + env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None +) -> tuple[_FakeSConsEnv, Callable[..., int]]: + """Run ccache.py.script against a fake SCons env and return (env, original SPAWN).""" + if original_spawn is None: + original_spawn = Mock(name="original_spawn", return_value=0) + scons_env = _FakeSConsEnv(SPAWN=original_spawn) + source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text() + with patch.dict(os.environ, env_vars, clear=True): + exec( # noqa: S102 + compile(source, "ccache.py", "exec"), + {"Import": lambda *_names: None, "env": scons_env}, + ) + return scons_env, original_spawn + + +def _scons_win32_escape(x: str) -> str: + """Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash.""" + if x[-1] == "\\": + x = x + "\\" + return '"' + x + '"' + + +def test_ccache_script_wraps_compiles_with_exported_path() -> None: + """The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup.""" + ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe" + scons_env, original_spawn = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path} + ) + spawn = scons_env["SPAWN"] + assert spawn is not original_spawn + + # A compile step is routed through ccache, with the same path used for + # the program and (escaped) as the first argument. + compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", + _scons_win32_escape, + ccache_path, + [_scons_win32_escape(ccache_path), *compile_args], + {}, + ) + + # Link steps pass through untouched. + original_spawn.reset_mock() + link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {} + ) + + +@pytest.mark.parametrize( + "env_vars", + [ + pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"), + pytest.param({}, id="unset"), + ], +) +def test_ccache_script_leaves_spawn_alone_without_path( + env_vars: dict[str, str], +) -> None: + """Without both the enable flag and a path, SPAWN is not replaced.""" + scons_env, original_spawn = _load_ccache_script(env_vars) + assert scons_env["SPAWN"] is original_spawn + + +def _scons_win32_spawn( + sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict +) -> int: + r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``. + + SCons is not importable in the test environment (PlatformIO fetches it at + build time), so the lines that matter are mirrored here. The command line + SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess`` + instead (identical on Windows, where a string passes through untouched); + ``spawnve`` itself crashes inside pytest. + """ + return subprocess.run( + " ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False + ).returncode + + +_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER" +# Stands in for a compile: the "ccache" is really the Python interpreter, and +# the compile "flags" make it write a marker file so the test can tell whether +# the wrapped command actually ran to completion. +_FAKE_COMPILE_ARGS = [ + "-c", + f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')", +] + + +def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int: + """Run one wrapped compile step the way SCons does on Windows.""" + child_env = {**os.environ, _MARKER_ENV: str(marker)} + return scons_env["SPAWN"]( + os.environ.get("COMSPEC", "cmd.exe"), + _scons_win32_escape, + "xtensa-lx106-elf-gcc", + [_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS], + child_env, + ) + + +_WINDOWS_ONLY = pytest.mark.skipif( + sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows" +) + + +@_WINDOWS_ONLY +def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: + r"""With a ``\\?\`` which result, the real probe runs the stripped binary. + + The probe therefore validates the exact string the build will execute + through ``cmd.exe``; probing the verbatim path instead would pass even + when the stripped path is unusable (``CreateProcess`` accepts + extended-length paths, ``cmd.exe`` does not). + """ + CORE.build_path = setup_core / "build" / "test" + assert not sys.executable.startswith("\\\\?\\") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object( + toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable + ), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == sys.executable + + +@_WINDOWS_ONLY +@pytest.mark.parametrize( + ("prefix", "expect_ok"), + [ + pytest.param("", True, id="stripped-path-compiles"), + pytest.param("\\\\?\\", False, id="verbatim-path-fails"), + ], +) +def test_ccache_wrapper_through_cmd_exe( + tmp_path: Path, prefix: str, expect_ok: bool +) -> None: + r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not. + + The interpreter stands in for ccache; the spawn mirrors SCons on Windows. + The failing case is the mechanism behind #18399 ("The system cannot find + the path specified." on every compile step); should it ever start passing, + ``cmd.exe`` learned extended-length paths and the strip is no longer needed. + """ + marker = tmp_path / "compiled.txt" + scons_env, _ = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable}, + original_spawn=_scons_win32_spawn, + ) + assert scons_env["SPAWN"] is not _scons_win32_spawn + + rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker) + assert (rc == 0) is expect_ok + assert marker.exists() is expect_ok + if expect_ok: + assert marker.read_text() == "compiled" + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ From 8b637b339b109ceaab298dad6f748a4671afd420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:37:10 -0500 Subject: [PATCH 133/470] [vscode] Report the origin of an unexpected exception during validation (#18494) --- esphome/vscode.py | 20 +++++++++- tests/unit_tests/test_vscode.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f00..ba7b4e727b 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -3,12 +3,14 @@ from __future__ import annotations from io import StringIO import json from pathlib import Path +import sys +import traceback from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, DocumentRange +from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml @@ -97,6 +99,16 @@ def _ace_loader(fname: Path) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream) +def _format_unexpected_error(err: Exception) -> str: + """Describe a crash inside validation with the frame it came from.""" + message = f"Unexpected error while validating: {type(err).__name__}: {err}" + frames = traceback.extract_tb(err.__traceback__) + if not frames: + return message + frame = frames[-1] + return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})" + + def _print_version(): """Print ESPHome version.""" print( @@ -134,8 +146,12 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + except (EsphomeError, cv.Invalid) as err: vs.add_yaml_error(str(err)) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # stdout carries the JSON protocol; the full chain goes to stderr. + traceback.print_exc(file=sys.stderr) + vs.add_yaml_error(_format_unexpected_error(err)) else: for err in res.errors: try: diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 63bdf3e255..9b7d1e9504 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode +import esphome.config_validation as cv +from esphome.core import EsphomeError def _run_repl_test(input_data): @@ -126,3 +128,67 @@ packages: assert range["start_col"] == 2 assert range["end_line"] == 1 assert range["end_col"] == 7 + + +def _explode(*_args: object, **_kwargs: object) -> None: + raise AttributeError("'NoneType' object has no attribute 'get'") + + +def test_unexpected_error_reports_origin() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", _explode): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["validation_errors"] == [] + (error,) = result["yaml_errors"] + assert error["message"].startswith( + "Unexpected error while validating: AttributeError: " + "'NoneType' object has no attribute 'get' (" + ) + assert "test_vscode.py" in error["message"] + assert error["message"].endswith(" in _explode)") + + +def test_esphome_error_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "boom"}] + + +def test_invalid_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "bad value"}] + + +def test_format_unexpected_error_without_traceback() -> None: + message = vscode._format_unexpected_error(ValueError("boom")) + assert message == "Unexpected error while validating: ValueError: boom" From 8aa7db15e52e7842edb4e0ce634c58028b20f4fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 134/470] [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) --- esphome/components/esp32/__init__.py | 78 +++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7d43c3ac07..3065cdadad 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1073,6 +1073,26 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) @@ -1085,6 +1105,8 @@ def _detect_variant(value): # name rather than carrying a PIO board name through the IDF build. if CORE.using_toolchain_esp_idf: value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() return value if variant not in STANDARD_BOARDS: @@ -1095,22 +1117,8 @@ def _detect_variant(value): ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -1120,6 +1128,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -1131,6 +1147,9 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value @@ -1434,20 +1453,6 @@ def final_validate(config) -> None: path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -2518,15 +2523,14 @@ async def to_code(config): 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 - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, From 07fa16e2e74f9964da91147028b630a7436b5d0e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:38:18 -0400 Subject: [PATCH 135/470] [ci] Stop persisting the integration test ccache (#18504) --- .github/workflows/ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c81c783b5..c3f830a5aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,18 +341,6 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache - - name: Restore ccache (restore-only) - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). The - # bucket-name prefix prefers a same-bucket seed; the bare prefix falls - # back to any seed when the bucket layout differs from dev. - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - restore-keys: | - integration-ccache-${{ matrix.bucket.name }}- - integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -401,14 +389,6 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save ccache - # Pull request saves land in per-PR scopes nothing else can reuse; - # dev pushes seed the shared copy instead. - if: github.event_name != 'pull_request' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} import-time: name: Check import esphome.__main__ time From b7121940c85ca166fd344d5c6b3c37f49e228a24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:08:23 -0400 Subject: [PATCH 136/470] Update wheel requirement from <0.48,>=0.43 to >=0.43,<0.49 (#18459) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index afa6208cae..3185fe0a9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.49"] build-backend = "setuptools.build_meta" [project] From 17eed7055bf516797478e3c12724758e05e8f94c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:22:51 -0400 Subject: [PATCH 137/470] Bump resvg-py from 0.3.4 to 0.4.0 (#18460) 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 e4521859e7..740a8c1a79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.3.0 -resvg-py==0.3.4 +resvg-py==0.4.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 From b7cc271219467909b28f81f244710bc06b81f79f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:51 -0500 Subject: [PATCH 138/470] Bump bundled esphome-device-builder to 1.11.3 (#18505) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a8daeaaf6..50b698224c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 RUN \ platformio settings set enable_telemetry No \ From d2c3f749abb87fdc4f7740ef5f05b4d33772de77 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:23 -0500 Subject: [PATCH 139/470] Bump bundled esphome-device-builder to 1.11.4 (#18506) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50b698224c..5c21e07618 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 RUN \ platformio settings set enable_telemetry No \ From e26237e57d66ea9d8e064323bd9d12a83790499f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 -0500 Subject: [PATCH 140/470] Bump bundled esphome-device-builder to 1.11.5 (#18507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5c21e07618..1be10db3af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 RUN \ platformio settings set enable_telemetry No \ From f90b7760714a96a396bcda158bd7465b65888b69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 11:39:01 -0500 Subject: [PATCH 141/470] [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3f830a5aa..6afb8a9d22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,24 +545,29 @@ jobs: fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf From 7b7107556f4637c157a6bbdbae5bbd80cbd5f3ee Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:58 -0500 Subject: [PATCH 142/470] Bump bundled esphome-device-builder to 1.12.0 (#18514) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1be10db3af..18f705b501 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 RUN \ platformio settings set enable_telemetry No \ From 470226ca03dfee7b8b6d08589a15a237bba12499 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:12 -0700 Subject: [PATCH 143/470] [image] Restore defaults:/files: support for platform entries (#18032) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 3 +- esphome/components/image/__init__.py | 152 +++++++- esphome/components/runtime_image/__init__.py | 5 +- esphome/config.py | 17 + esphome/loader.py | 8 + tests/component_tests/image/test_init.py | 328 +++++++++++++++++- .../validate-platform-defaults.host.yaml | 21 ++ .../validate-platform-defaults.host.yaml | 24 ++ tests/unit_tests/test_config_normalization.py | 124 ++++++- 9 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 tests/components/animation/validate-platform-defaults.host.yaml create mode 100644 tests/components/image/validate-platform-defaults.host.yaml diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index d340d21490..feced063d0 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -23,6 +23,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -200,7 +201,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84d..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: return get_all_image_metadata().get(image_id) +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + # --------------------------------------------------------------------------- # Legacy top-level component -> `image:` platform deprecation helpers # -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. return bool(config) and all( - isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. return False # A single image dict, or the grouped `defaults:`/`images:`/type-key form. return ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(merged, {})) def _add_entries(entries: object, extra: dict) -> None: # `entries` may be a single image dict or a list of them; non-dict diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index d8517d4493..9fa32a5a65 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER from esphome.components.image import ( IMAGE_TYPE, Image_, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), cv.Optional(CONF_RESIZE): cv.dimensions, cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "BIG_ENDIAN", "LITTLE_ENDIAN", upper=True - ), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(), cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), } diff --git a/esphome/config.py b/esphome/config.py index 987bb9c96a..13ec744ce4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep): elif not isinstance(self.conf, list): result[self.domain] = self.conf = [self.conf] + # Permanent expansion hook: a platform-tagged entry may expand into + # several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only. + if (expand := component.expand_platform_config) is not None and all( + isinstance(entry, dict) and CONF_PLATFORM in entry + for entry in self.conf + ): + with result.catch_error(path): + expanded = expand(self.conf) + if not isinstance(expanded, list): + # A non-list return is a component bug (not a user error): + # raise explicitly (survives -O/-OO) so it escapes catch_error. + raise TypeError( + f"{self.domain}: EXPAND_PLATFORM_CONFIG must " + f"return a list, got {type(expanded).__name__}" + ) + result[self.domain] = self.conf = expanded + # Process AUTO_LOAD _process_auto_load(result, component, path) diff --git a/esphome/loader.py b/esphome/loader.py index f994f0c5eb..23c6d1bfa5 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -164,6 +164,14 @@ class ComponentManifest: """ return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property + def expand_platform_config( + self, + ) -> Callable[[list[ConfigType]], list[ConfigType]] | None: + """Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged + entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors).""" + return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f52c477c85..fad8b7df09 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -21,16 +21,20 @@ from esphome.components.image import ( CONF_OPAQUE, CONF_TRANSPARENCY, PLATFORM_FILE, + _expand_platform_entry, _flatten_legacy_image_config, _is_legacy_image_format, _is_new_image_format, _migrate_legacy_image_config, + expand_platform_config, get_all_image_metadata, get_image_metadata, ) from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, + CONF_FILES, CONF_ID, CONF_PLATFORM, CONF_RAW_DATA_ID, @@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None: assert out[0][CONF_BYTE_ORDER] == "little_endian" +def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None: + """The legacy flattener drops an incompatible byte_order even when written directly on the entry.""" + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + def test_flatten_skips_meta_and_unknown_keys() -> None: out = _flatten_legacy_image_config( { @@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform( ), pytest.param({"foo": 1}, False, id="dict_unknown_keys"), pytest.param("a string", False, id="scalar"), + # A `platform:`-tagged dict is the new format written without list brackets. + pytest.param( + {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}, + False, + id="platform_tagged_flat_dict", + ), + pytest.param( + { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="platform_tagged_defaults_files_dict", + ), + # `files:` without `platform:` is not legacy either -- the flattener has no branch for it. + pytest.param( + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="defaults_files_dict_without_platform", + ), + # Same as above in a list -- without this exclusion it would be silently + # migrated to a hard-coded `platform: file` instead of raising the error. + pytest.param( + [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + } + ], + False, + id="defaults_files_list_entry_without_platform", + ), ], ) def test_is_legacy_image_format(config: object, expected: bool) -> None: @@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None: def test_migrate_returns_none_for_invalid_legacy_shapes( config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Unrecognised shapes are not migrated (and emit no warning) so normal - platform validation surfaces a proper error instead of silently dropping - the offending input.""" + """Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them.""" with caplog.at_level(logging.WARNING): assert _migrate_legacy_image_config(config) is None assert "deprecated" not in caplog.text +def test_migrate_returns_none_for_mapping_form_defaults_files() -> None: + """A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator.""" + config = { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None: + """`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has + no `files:` branch and would silently return `[]`.""" + config = { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None: + """Same, in a list -- previously the list branch migrated it to a hard-coded + `platform: file` instead of raising a missing-platform error.""" + config = [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + ] + assert _migrate_legacy_image_config(config) is None + + # --------------------------- end legacy migration -------------------------- +def test_expand_platform_entry_passes_through_plain_entry() -> None: + entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"} + assert _expand_platform_entry(0, entry) == [entry] + + +def test_expand_platform_entry_expands_files_with_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png", "type": "GRAYSCALE"}, + ], + } + assert _expand_platform_entry(0, entry) == [ + { + CONF_PLATFORM: "file", + "id": "img1", + "file": "foo.png", + "type": "RGB565", + "transparency": "opaque", + }, + { + CONF_PLATFORM: "file", + "id": "img2", + "file": "bar.png", + "type": "GRAYSCALE", + "transparency": "opaque", + }, + ] + + +def test_expand_platform_entry_files_without_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + assert _expand_platform_entry(0, entry) == [ + {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + ] + + +def test_expand_platform_entry_preserves_source_range() -> None: + """A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there.""" + from esphome import yaml_util + + file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"}) + file_entry._esp_range = "sentinel-range" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [file_entry], + } + [out] = _expand_platform_entry(0, entry) + assert isinstance(out, yaml_util.ESPHomeDataBase) + assert out.esp_range == "sentinel-range" + + +def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None: + """Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + [out] = _expand_platform_entry(0, entry) + assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + + +def test_expand_platform_entry_per_file_overrides_win() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["type"] == "BINARY" + + +def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None: + """A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"}, + CONF_FILES: [ + {"id": "a", "file": "x.png"}, + {"id": "b", "file": "y.png", "type": "binary"}, + ], + } + out = _expand_platform_entry(0, entry) + assert out[0]["byte_order"] == "little_endian" + assert "byte_order" not in out[1] + + +def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None: + """A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}], + } + with pytest.raises(cv.Invalid, match="did you mean") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "big_endian" + + +def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None: + """A `byte_order` written directly on the entry is kept so validate_settings raises the normal error.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565"}, + CONF_FILES: [ + { + "id": "a", + "file": "x.png", + "type": "binary", + "byte_order": "little_endian", + } + ], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "little_endian" + + +def test_expand_platform_entry_defaults_without_files_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}} + with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_null_files_raises_not_empty() -> None: + """A `files:` key with no value parses to `None` and must be reported clearly.""" + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None: + """An explicit `files: []` must not silently drop the whole platform entry.""" + entry = {CONF_PLATFORM: "file", CONF_FILES: []} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_with_stray_key_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png"}], + "extra": 1, + } + with pytest.raises(cv.Invalid, match="cannot be combined with"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_id_in_defaults_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_ID: "a"}, + CONF_FILES: [{"file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_defaults_raises() -> None: + """`platform:` inside `defaults:` would silently reassign every file's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_PLATFORM: "animation"}, + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_file_entry_raises() -> None: + """`platform:` on a `files:` item must not silently override the entry's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_not_list_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"} + with pytest.raises(cv.Invalid, match="must be a list"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_defaults_not_mapping_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: "not-a-mapping", + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_file_item_not_mapping_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]} + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None: + config = [ + { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png"}, + ], + }, + {CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"}, + ] + out = expand_platform_config(config) + assert [entry["id"] for entry in out] == ["img1", "img2", "plain"] + + +def test_expand_platform_config_ignores_non_platform_entries() -> None: + # Not expanded here -- legacy_config_migrate runs before this hook and is + # responsible for tagging/flattening pre-platform shapes. + config = ["not-a-platform-entry"] + assert expand_platform_config(config) == config + + +# --------------------- end defaults/files expansion ------------------------- + + def test_validate_image_final_defaults_to_little_endian() -> None: config = {CONF_FILE: "x.png"} validate_image_final(config) diff --git a/tests/components/animation/validate-platform-defaults.host.yaml b/tests/components/animation/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..034497c548 --- /dev/null +++ b/tests/components/animation/validate-platform-defaults.host.yaml @@ -0,0 +1,21 @@ +# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: animation + defaults: + type: rgb565 + transparency: opaque + resize: 50x50 + files: + - id: platform_defaults_animation + file: $component_dir/anim.gif + - id: platform_defaults_animation_rgb + file: $component_dir/anim.apng + type: rgb diff --git a/tests/components/image/validate-platform-defaults.host.yaml b/tests/components/image/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..e1b3037cc3 --- /dev/null +++ b/tests/components/image/validate-platform-defaults.host.yaml @@ -0,0 +1,24 @@ +# `platform: file` entry using the `defaults:`/`files:` shape, including the +# per-type byte_order drop when an entry overrides to a non-endian type. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: file + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + files: + - id: platform_defaults_image + file: ../../pnglogo.png + - id: platform_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index c8b7b63094..04363ad45b 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import config, yaml_util +from esphome import config, config_validation as cv, yaml_util from esphome.core import CORE, AutoLoad from esphome.types import ConfigType @@ -127,12 +127,14 @@ def _run_load_step( domain: str, conf: object, migrate: Callable[[ConfigType], list | None] | None, + expand: Callable[[list], list] | None = None, ) -> config.Config: - """Run a LoadValidationStep for a platform component with a given migrate hook.""" + """Run a LoadValidationStep for a platform component with given hooks.""" component = Mock() component.is_platform_component = True component.multi_conf_no_default = False component.legacy_config_migrate = migrate + component.expand_platform_config = expand result = config.Config() with ( @@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None: assert result["image"] == [auto] +# --------------------------------------------------------------------------- +# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart +# to legacy_config_migrate; runs after legacy migration/list normalization. +# --------------------------------------------------------------------------- + + +def test_expand_hook_rewrites_conf() -> None: + """A config the expand hook rewrites is replaced with the expanded list.""" + expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}] + expand = Mock(return_value=expanded) + + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + expand.assert_called_once_with([{"platform": "file", "id": "a"}]) + assert result["image"] == expanded + + +def test_expand_hook_absent_is_noop() -> None: + """A platform component without the hook is left as normalized by the + existing list-wrapping logic.""" + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None) + + assert result["image"] == [{"platform": "file", "id": "a"}] + + +def test_expand_hook_runs_after_legacy_migrate() -> None: + """The expand hook sees the already-migrated list, not the raw legacy conf.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + expand = Mock(side_effect=lambda conf: conf) + + _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand) + + expand.assert_called_once_with(migrated) + + +def test_expand_hook_skipped_for_non_dict_entry() -> None: + """Malformed entries are left alone; the hook only sees `platform:`-tagged dicts.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", ["not-a-dict"], None, expand) + + expand.assert_not_called() + assert result["image"] == ["not-a-dict"] + + +def test_expand_hook_skipped_for_entry_missing_platform_key() -> None: + """A dict entry missing the `platform:` key is left alone -- the normal + per-entry error reporting further down catches this case instead.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", [{"id": "a"}], None, expand) + + expand.assert_not_called() + assert result["image"] == [{"id": "a"}] + + +def test_expand_hook_skipped_for_autoload() -> None: + """A non-empty AutoLoad reaching the hook stage is left alone.""" + expand = Mock(side_effect=lambda conf: conf) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, None, expand) + + expand.assert_not_called() + assert result["image"] == [auto] + + +def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None: + """The guard does not block the normal, well-formed case.""" + expand = Mock(side_effect=lambda conf: conf) + conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}] + + result = _run_load_step("image", conf, None, expand) + + expand.assert_called_once_with(conf) + assert result["image"] == conf + + +def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None: + """A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs.""" + expand = Mock(side_effect=cv.Invalid("bad shape")) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0].path == ["image"] + assert "bad shape" in str(result.errors[0]) + assert result["image"] == pre_expand_conf + + +def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None: + """`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended).""" + already_resolved_error = cv.FinalExternalInvalid( + "bad shape", path=["image", 3, "files"] + ) + expand = Mock(side_effect=already_resolved_error) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0] is already_resolved_error + assert result.errors[0].path == ["image", 3, "files"] + assert result["image"] == pre_expand_conf + + +def test_expand_hook_non_list_return_raises_type_error() -> None: + """A non-list return is a component bug: it escapes as an uncaught TypeError + (explicit raise survives -O/-OO).""" + expand = Mock(return_value={"not": "a list"}) + + with pytest.raises(TypeError, match="must return a list"): + _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 47da743d11ec42374a9026d8b473174d20489077 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:09:12 -0700 Subject: [PATCH 144/470] [ai] Add instructions for concise comments (#18522) --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fa0f61c263..f006ee6087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -763,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, using standard technical terms only when required. Ensure the text is readily comprehensible to a wide audience, including non-native English speakers. + +## 10. Code Comments + +Code comments on individual lines should be used only where necessary to flag issues that may not be obvious +on a simple reading of the code. Keep them short (e.g. 1 or 2 lines). + +Function and method comment blocks may include more detail as required to make +calling contracts clear and document parameter usage, but should still be kept concise. + +Avoid redundancy and repetition; comments should never simply restate what the code already says. From 0b1065feee095c82220fa4e9d1cd6b3b164aa82a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 16:30:34 -0500 Subject: [PATCH 145/470] [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) --- .github/workflows/ci-api-proto.yml | 28 +++++++++- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1ccff96f24..63219a1dbc 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -41,10 +41,32 @@ jobs: version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afb8a9d22..35148de0c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -323,7 +339,8 @@ jobs: integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # Must match seed-apt-cache's image: the apt cache key has no OS in it. + runs-on: ubuntu-24.04 needs: - common - determine-jobs @@ -335,12 +352,16 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install ccache - # Speeds up the host compiles: tests in a bucket compile overlapping - # component sets, so later tests reuse earlier tests' objects. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ccache + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -421,6 +442,7 @@ jobs: benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs @@ -461,6 +483,41 @@ jobs: fi echo "binary=$BINARY" >> $GITHUB_OUTPUT + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true + run: | + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: @@ -884,12 +941,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1424,6 +1486,7 @@ jobs: # this check. needs: - common + - seed-apt-cache - determine-jobs - ci-custom - pylint From 9daae377fca5eea6d1f39d13fa33e790d50b2f9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:14 -0500 Subject: [PATCH 146/470] [api] Bump noise-c to 0.1.20 (#18482) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index cdc0d97c49..3e69d5842c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.19") + cg.add_library("esphome/noise-c", "0.1.20") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 39600d622a..13bb5a556f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.19 ; used by api + esphome/noise-c@0.1.20 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 200a1644a5d12c30e4f0d562f1e0132b96482dc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:33 -0500 Subject: [PATCH 147/470] [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) --- .github/workflows/ci.yml | 17 ++++++++++++++--- tests/benchmarks/components/api/__init__.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603e68ad7..2075fde9ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,10 +460,21 @@ jobs: - name: Build benchmarks id: build run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0d02e0b054..0565bc5330 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # components have hardware dependencies (BLE/UART/RMT); lightweight # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_ZWAVE_PROXY") From a99a8f364e8bad032d89d65e18e2470fd2df2267 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:32:37 -0500 Subject: [PATCH 148/470] [api] Bump noise-c to 0.1.21 (#18484) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3e69d5842c..912d580a0f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.20") + cg.add_library("esphome/noise-c", "0.1.21") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 13bb5a556f..4c372cc0bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.20 ; used by api + esphome/noise-c@0.1.21 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 10e592fa3a51b301644b5a742c75088cc3ab286a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 18 Aug 2026 09:09:12 -0700 Subject: [PATCH 149/470] [modbus] CRC scan all unknown function codes (#18483) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/modbus/modbus.cpp | 33 ++-- esphome/components/modbus/modbus.h | 2 +- esphome/components/modbus/modbus_helpers.h | 32 ++++ tests/components/modbus/common.h | 36 +++++ .../modbus/modbus_unknown_function_test.cpp | 141 ++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/components/modbus/modbus_unknown_function_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5305f6313f..e4bd51ad5a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() { this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); } -uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { - // Custom functions could be any length - we have to rely on the CRC to determine completeness. +uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { + // Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values) + // could be any length - we have to rely on the CRC to determine completeness. // If a CRC match is never found, the buffer will eventually overflow and be cleared. const uint8_t *raw = &this->rx_buffer_[0]; const size_t size = this->rx_buffer_.size(); - for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { - if (crc16(raw, len) == 0) - return len; + const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); + if (min_length > max_len) + return 0; + // The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value, + // so we seed once over the first min_length bytes and extend one byte at a time instead of + // recomputing the whole prefix for every candidate length. + uint16_t crc = crc16(raw, min_length); + if (crc == 0) + return min_length; + for (uint16_t len = min_length; len < max_len; len++) { + crc = crc16(&raw[len], 1, crc); + if (crc == 0) + return len + 1; } return 0; } @@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; @@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index dfe4a4872d..bb303c43a8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. - uint16_t find_custom_frame_end_(uint16_t min_length) const; + uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index c737e206c0..b2454e6f14 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } +/// True for any function code whose frame length the parsers cannot predict - everything the +/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list +/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined +/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes +/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. +/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - +/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what +/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary +/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec +/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one +/// pays (recovery by timeout instead of an immediate CRC failure). +inline bool is_function_code_unknown_length(uint8_t function_code) { + switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + case FunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FIFO_QUEUE: + return false; + default: + return true; + } +} + // Returns the expected length of a server response PDU based on the function code. // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index d03ccf8ec3..e6c37b0e6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include #include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus::testing { @@ -30,4 +33,37 @@ class RecordingUART : public NullUART { std::vector written; }; +// A UART the test can inject received bytes into, so frames travel the full receive path +// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded. +class InjectableUART : public RecordingUART { + public: + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + + // Queues a complete wire frame: address + PDU + CRC16 (low byte first). + void inject_frame(uint8_t address, std::span pdu) { + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp new file mode 100644 index 0000000000..8b91d088b8 --- /dev/null +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records custom-response dispatches so tests can assert an unknown-length frame reached the device. +class CustomRecordingDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->statuses.push_back(status); + } + std::vector> requests; + std::vector> responses; + std::vector statuses; +}; + +// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test. +class SilentServerDevice : public ModbusServerDevice {}; + +// Drives full client frames through the server hub's receive path (same shape as the broadcast tests). +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser. + // Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span data) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(data.size() + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end()); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the +// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - +// must classify as unknown length. The exception flag masks off first. +TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { + for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + // Exception replies classify by their base code. + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); + EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); + // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. + for (int fc = 0; fc <= 0xFF; fc++) { + if (helpers::is_function_code_custom(fc)) + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; + } + EXPECT_FALSE(helpers::is_function_code_custom(0x49)); + + // Derived contract check: the helper must say "unknown" exactly when both length parsers fall + // through to default. With a zero-filled max-size PDU every explicit case returns at least 2 + // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The + // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() + // switches on the unmasked byte and server_pdu_length() early-returns the exception length. + for (int fc = 0; fc <= 0x7F; fc++) { + const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "client_pdu_length disagrees for fc 0x" << std::hex << fc; + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "server_pdu_length disagrees for fc 0x" << std::hex << fc; + } +} + +// A response with a function code outside the user-defined ranges (0x49) has no length case in +// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already +// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the +// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device. +TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) { + InjectableUART uart; + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + CustomRecordingDevice device(&hub, 0x02); + + const uint8_t request[] = {0x49, 0x01}; + ASSERT_TRUE(device.queue_pdu(request)); + hub.loop(); // transmit + ASSERT_FALSE(uart.written.empty()); + + const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB}; + uart.inject_frame(0x02, response_pdu); + hub.loop(); // receive + parse + match + dispatch + + ASSERT_EQ(device.responses.size(), 1u); + EXPECT_EQ(device.requests[0], std::vector(request, request + sizeof(request))); + EXPECT_EQ(device.responses[0], std::vector(response_pdu, response_pdu + sizeof(response_pdu))); + EXPECT_FALSE(device.statuses[0].has_value()); +} + +// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC +// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails +// to parse and the client gets silence instead of the exception. +TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + SilentServerDevice device; + device.set_address(0x02); + hub.register_device(&device); + + const uint8_t data[] = {0x02, 0xAA, 0xBB}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data)); + + // Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC. + std::vector expected = {0x02, 0xC9, 0x01}; + uint16_t crc = crc16(expected.data(), expected.size()); + expected.push_back(crc & 0xFF); + expected.push_back(crc >> 8); + EXPECT_EQ(uart.written, expected); +} + +} // namespace esphome::modbus::testing From 2df953f3d7c0b022cd3a75c05ab2ca0d63cc39f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:36:57 -0500 Subject: [PATCH 150/470] [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) --- esphome/platformio/ccache.py.script | 9 +- esphome/platformio/toolchain.py | 63 +++-- tests/unit_tests/test_platformio_toolchain.py | 240 +++++++++++++++++- 3 files changed, 286 insertions(+), 26 deletions(-) diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script index cc08a8c044..22592a2398 100644 --- a/esphome/platformio/ccache.py.script +++ b/esphome/platformio/ccache.py.script @@ -1,5 +1,4 @@ import os -import shutil # pylint: disable=E0602 Import("env") # noqa @@ -9,15 +8,17 @@ Import("env") # noqa # esphome/platformio/toolchain.py); this script only supplies the SCons-level # mechanism. # +# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has +# already stripped the Windows \\?\ prefix that cmd.exe cannot run. +# # This is a "pre" script, so the platform's builder (which sets CC/CXX and # clones the construction environment for framework and library builds) runs # after it. Replacing CC/CXX here would be overwritten, and replacing them in # a "post" script would miss the already-cloned library environments. Wrapping # SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler # invocation from every environment funnels through it at execution time. -if ( - os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" - and (ccache_path := shutil.which("ccache")) is not None +if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and ( + ccache_path := os.environ.get("ESPHOME_CCACHE_PATH") ): original_spawn = env["SPAWN"] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 08a4fcff78..d76581d032 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -60,6 +60,9 @@ def _strip_win_long_path_prefix(path: str) -> str: "The system cannot find the path specified." Stripping the prefix early keeps the path shell-quotable. + Also applied to the ccache path exported by ``_ccache_env()``, which + ``shutil.which`` can return with the same prefix. + No-op on non-Windows platforms. """ if sys.platform != "win32": @@ -235,8 +238,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_usable() -> bool: - """Return True when the ``ccache`` on PATH actually runs. +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. ``shutil.which`` proves existence, not runnability: on Windows it also matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose @@ -244,9 +247,6 @@ def _ccache_usable() -> bool: step with an opaque OS error, so probe once and fall back to compiling without ccache when the probe fails. """ - ccache = shutil.which("ccache") - if ccache is None: - return False try: subprocess.run( [ccache, "--version"], @@ -265,14 +265,29 @@ def _ccache_usable() -> bool: def _ccache_env() -> dict[str, str]: - """Return ccache settings for PlatformIO builds. + r"""Return ccache settings for PlatformIO builds. Enabled by default whenever the ``ccache`` binary is on PATH; set ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to - force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` - so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, - which wraps compiler invocations inside SCons) only have to check for - ``"1"`` instead of re-implementing the policy. + force it on without the runnability probe; a binary is still needed). + The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the + binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts + (the shared ``ccache.py`` extra script, which wraps compiler invocations + inside SCons) only have to check for ``"1"`` and use the path as given + instead of re-implementing the policy. + + The path is exported rather than looked up again inside SCons because + ``shutil.which`` can return a Windows extended-length ``\\?\`` path + (ESPHome Desktop puts its bundled ccache on PATH that way). Such a path + runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, + but SCons runs every compile through ``cmd.exe``, which fails on it with + "The system cannot find the path specified." (#18399), so the prefix is + stripped here with ``_strip_win_long_path_prefix()`` before the + runnability probe, which therefore validates the exact string the build + will execute. + ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the + script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this + function always sets both or neither. The returned values are merged into the environment of the PlatformIO subprocess only, never into ``os.environ``: a long-running process @@ -293,13 +308,27 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - if "ESPHOME_CCACHE_ENABLE" in os.environ: - enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") - else: - enabled = _ccache_usable() - env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} - if not enabled: - return env + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return {"ESPHOME_CCACHE_ENABLE": "0"} + ccache_path = shutil.which("ccache") + if ccache_path is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return {"ESPHOME_CCACHE_ENABLE": "0"} + # Strip before probing so the probe validates (and the failure warning + # names) the exact string the build will execute through cmd.exe. + ccache_path = _strip_win_long_path_prefix(ccache_path) + # An explicit opt-in skips the runnability probe. + if not explicit and not _ccache_runs(ccache_path): + return {"ESPHOME_CCACHE_ENABLE": "0"} + env = { + "ESPHOME_CCACHE_ENABLE": "1", + "ESPHOME_CCACHE_PATH": ccache_path, + } # build_path is set during preload for every config-loading command, so it # being unset means a caller built the environment too early; fail loudly # rather than with an opaque TypeError from Path(None). diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index eebb0b8cd7..172b288c25 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json @@ -437,6 +437,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert env["CCACHE_DIR"].endswith("platformio-ccache") assert env["CCACHE_NOHASHDIR"] == "true" @@ -446,17 +447,35 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ -def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: - """Ccache stays off when the binary is not on PATH.""" +@pytest.mark.parametrize( + ("env_vars", "expect_warning"), + [ + pytest.param({}, False, id="default"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"), + ], +) +def test_ccache_env_disabled_without_binary( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + env_vars: dict[str, str], + expect_warning: bool, +) -> None: + """Ccache stays off when the binary is not on PATH, even when forced on. + + A deliberate opt-in that finds no binary is downgraded with a warning so + the user can tell why it had no effect; the default path stays quiet. + """ CORE.build_path = setup_core / "build" / "test" with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env_vars, clear=True), patch.object(toolchain.shutil, "which", return_value=None), + caplog.at_level("WARNING"), ): env = toolchain._ccache_env() assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + assert ("no ccache binary is on PATH" in caplog.text) is expect_warning @pytest.mark.parametrize( @@ -489,14 +508,47 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), patch.object(toolchain.subprocess, "run") as mock_probe, ): env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + # The binary's location is still handed to the build script. + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" mock_probe.assert_not_called() +def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: + r"""A ``\\?\`` ccache path from PATH is exported without the prefix. + + That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``. + """ + CORE.build_path = setup_core / "build" / "test" + prefixed = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder" + "\\ccache\\ccache.exe" + ) + stripped = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=True), + # shutil.which is patched, so the win32 code path of the real + # implementation (which crashes on a POSIX host) is never reached. + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch.object(toolchain.shutil, "which", return_value=prefixed), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == stripped + # The probe validates the exact string the build will execute. + assert mock_probe.call_args[0][0] == [stripped, "--version"] + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -516,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -563,8 +615,10 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( env = mock_run_external_process.call_args[1]["env"] assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "ESPHOME_CCACHE_PATH" not in os.environ assert "CCACHE_BASEDIR" not in os.environ @@ -613,6 +667,182 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for ccache.py.""" + + def Replace(self, **kwargs: object) -> None: # noqa: N802 + self.update(kwargs) + + +def _load_ccache_script( + env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None +) -> tuple[_FakeSConsEnv, Callable[..., int]]: + """Run ccache.py.script against a fake SCons env and return (env, original SPAWN).""" + if original_spawn is None: + original_spawn = Mock(name="original_spawn", return_value=0) + scons_env = _FakeSConsEnv(SPAWN=original_spawn) + source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text() + with patch.dict(os.environ, env_vars, clear=True): + exec( # noqa: S102 + compile(source, "ccache.py", "exec"), + {"Import": lambda *_names: None, "env": scons_env}, + ) + return scons_env, original_spawn + + +def _scons_win32_escape(x: str) -> str: + """Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash.""" + if x[-1] == "\\": + x = x + "\\" + return '"' + x + '"' + + +def test_ccache_script_wraps_compiles_with_exported_path() -> None: + """The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup.""" + ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe" + scons_env, original_spawn = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path} + ) + spawn = scons_env["SPAWN"] + assert spawn is not original_spawn + + # A compile step is routed through ccache, with the same path used for + # the program and (escaped) as the first argument. + compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", + _scons_win32_escape, + ccache_path, + [_scons_win32_escape(ccache_path), *compile_args], + {}, + ) + + # Link steps pass through untouched. + original_spawn.reset_mock() + link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {} + ) + + +@pytest.mark.parametrize( + "env_vars", + [ + pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"), + pytest.param({}, id="unset"), + ], +) +def test_ccache_script_leaves_spawn_alone_without_path( + env_vars: dict[str, str], +) -> None: + """Without both the enable flag and a path, SPAWN is not replaced.""" + scons_env, original_spawn = _load_ccache_script(env_vars) + assert scons_env["SPAWN"] is original_spawn + + +def _scons_win32_spawn( + sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict +) -> int: + r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``. + + SCons is not importable in the test environment (PlatformIO fetches it at + build time), so the lines that matter are mirrored here. The command line + SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess`` + instead (identical on Windows, where a string passes through untouched); + ``spawnve`` itself crashes inside pytest. + """ + return subprocess.run( + " ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False + ).returncode + + +_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER" +# Stands in for a compile: the "ccache" is really the Python interpreter, and +# the compile "flags" make it write a marker file so the test can tell whether +# the wrapped command actually ran to completion. +_FAKE_COMPILE_ARGS = [ + "-c", + f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')", +] + + +def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int: + """Run one wrapped compile step the way SCons does on Windows.""" + child_env = {**os.environ, _MARKER_ENV: str(marker)} + return scons_env["SPAWN"]( + os.environ.get("COMSPEC", "cmd.exe"), + _scons_win32_escape, + "xtensa-lx106-elf-gcc", + [_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS], + child_env, + ) + + +_WINDOWS_ONLY = pytest.mark.skipif( + sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows" +) + + +@_WINDOWS_ONLY +def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: + r"""With a ``\\?\`` which result, the real probe runs the stripped binary. + + The probe therefore validates the exact string the build will execute + through ``cmd.exe``; probing the verbatim path instead would pass even + when the stripped path is unusable (``CreateProcess`` accepts + extended-length paths, ``cmd.exe`` does not). + """ + CORE.build_path = setup_core / "build" / "test" + assert not sys.executable.startswith("\\\\?\\") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object( + toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable + ), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == sys.executable + + +@_WINDOWS_ONLY +@pytest.mark.parametrize( + ("prefix", "expect_ok"), + [ + pytest.param("", True, id="stripped-path-compiles"), + pytest.param("\\\\?\\", False, id="verbatim-path-fails"), + ], +) +def test_ccache_wrapper_through_cmd_exe( + tmp_path: Path, prefix: str, expect_ok: bool +) -> None: + r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not. + + The interpreter stands in for ccache; the spawn mirrors SCons on Windows. + The failing case is the mechanism behind #18399 ("The system cannot find + the path specified." on every compile step); should it ever start passing, + ``cmd.exe`` learned extended-length paths and the strip is no longer needed. + """ + marker = tmp_path / "compiled.txt" + scons_env, _ = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable}, + original_spawn=_scons_win32_spawn, + ) + assert scons_env["SPAWN"] is not _scons_win32_spawn + + rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker) + assert (rc == 0) is expect_ok + assert marker.exists() is expect_ok + if expect_ok: + assert marker.read_text() == "compiled" + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ From 6084314cc9c029b4b6b131a92665d98d4046e464 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:37:10 -0500 Subject: [PATCH 151/470] [vscode] Report the origin of an unexpected exception during validation (#18494) --- esphome/vscode.py | 20 +++++++++- tests/unit_tests/test_vscode.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f00..ba7b4e727b 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -3,12 +3,14 @@ from __future__ import annotations from io import StringIO import json from pathlib import Path +import sys +import traceback from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, DocumentRange +from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml @@ -97,6 +99,16 @@ def _ace_loader(fname: Path) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream) +def _format_unexpected_error(err: Exception) -> str: + """Describe a crash inside validation with the frame it came from.""" + message = f"Unexpected error while validating: {type(err).__name__}: {err}" + frames = traceback.extract_tb(err.__traceback__) + if not frames: + return message + frame = frames[-1] + return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})" + + def _print_version(): """Print ESPHome version.""" print( @@ -134,8 +146,12 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + except (EsphomeError, cv.Invalid) as err: vs.add_yaml_error(str(err)) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # stdout carries the JSON protocol; the full chain goes to stderr. + traceback.print_exc(file=sys.stderr) + vs.add_yaml_error(_format_unexpected_error(err)) else: for err in res.errors: try: diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 63bdf3e255..9b7d1e9504 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode +import esphome.config_validation as cv +from esphome.core import EsphomeError def _run_repl_test(input_data): @@ -126,3 +128,67 @@ packages: assert range["start_col"] == 2 assert range["end_line"] == 1 assert range["end_col"] == 7 + + +def _explode(*_args: object, **_kwargs: object) -> None: + raise AttributeError("'NoneType' object has no attribute 'get'") + + +def test_unexpected_error_reports_origin() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", _explode): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["validation_errors"] == [] + (error,) = result["yaml_errors"] + assert error["message"].startswith( + "Unexpected error while validating: AttributeError: " + "'NoneType' object has no attribute 'get' (" + ) + assert "test_vscode.py" in error["message"] + assert error["message"].endswith(" in _explode)") + + +def test_esphome_error_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "boom"}] + + +def test_invalid_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "bad value"}] + + +def test_format_unexpected_error_without_traceback() -> None: + message = vscode._format_unexpected_error(ValueError("boom")) + assert message == "Unexpected error while validating: ValueError: boom" From b768e2a1ce796f7055f9fcba8e8b3494798ce8fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 152/470] [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) --- esphome/components/esp32/__init__.py | 78 +++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..2c06ebac9a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1070,6 +1070,26 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) @@ -1082,6 +1102,8 @@ def _detect_variant(value): # name rather than carrying a PIO board name through the IDF build. if CORE.using_toolchain_esp_idf: value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() return value if variant not in STANDARD_BOARDS: @@ -1092,22 +1114,8 @@ def _detect_variant(value): ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -1117,6 +1125,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -1128,6 +1144,9 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value @@ -1431,20 +1450,6 @@ def final_validate(config): path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -2517,15 +2522,14 @@ async def to_code(config): 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 - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, From 7418fcce8d8f154bceb088f1ad10782c6dadb4ca Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:38:18 -0400 Subject: [PATCH 153/470] [ci] Stop persisting the integration test ccache (#18504) --- .github/workflows/ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2075fde9ef..0d8f35ed83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,18 +341,6 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache - - name: Restore ccache (restore-only) - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). The - # bucket-name prefix prefers a same-bucket seed; the bare prefix falls - # back to any seed when the bucket layout differs from dev. - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - restore-keys: | - integration-ccache-${{ matrix.bucket.name }}- - integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -401,14 +389,6 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save ccache - # Pull request saves land in per-PR scopes nothing else can reuse; - # dev pushes seed the shared copy instead. - if: github.event_name != 'pull_request' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} import-time: name: Check import esphome.__main__ time From e9e77d02a00d6d9b8f0661b0e4c4a025b4f697b9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:51 -0500 Subject: [PATCH 154/470] Bump bundled esphome-device-builder to 1.11.3 (#18505) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a8daeaaf6..50b698224c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 RUN \ platformio settings set enable_telemetry No \ From 74e22b5ad74308fbed86738bf63fbcaed9f0fd03 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:23 -0500 Subject: [PATCH 155/470] Bump bundled esphome-device-builder to 1.11.4 (#18506) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50b698224c..5c21e07618 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 RUN \ platformio settings set enable_telemetry No \ From 2c92a2498e5fb5632554f485eedd3446155d7e83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 -0500 Subject: [PATCH 156/470] Bump bundled esphome-device-builder to 1.11.5 (#18507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5c21e07618..1be10db3af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 RUN \ platformio settings set enable_telemetry No \ From 4a85c98285c1a2c38b2e5e9115fb4793b5b1d69f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:58 -0500 Subject: [PATCH 157/470] Bump bundled esphome-device-builder to 1.12.0 (#18514) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1be10db3af..18f705b501 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 RUN \ platformio settings set enable_telemetry No \ From b3fda9973ebd67fcafb26b4f3b7a831427ecafff Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:12 -0700 Subject: [PATCH 158/470] [image] Restore defaults:/files: support for platform entries (#18032) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 3 +- esphome/components/image/__init__.py | 152 +++++++- esphome/components/runtime_image/__init__.py | 5 +- esphome/config.py | 17 + esphome/loader.py | 8 + tests/component_tests/image/test_init.py | 328 +++++++++++++++++- .../validate-platform-defaults.host.yaml | 21 ++ .../validate-platform-defaults.host.yaml | 24 ++ tests/unit_tests/test_config_normalization.py | 124 ++++++- 9 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 tests/components/animation/validate-platform-defaults.host.yaml create mode 100644 tests/components/image/validate-platform-defaults.host.yaml diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index b54c3f2adf..212c778763 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -23,6 +23,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -200,7 +201,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84d..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: return get_all_image_metadata().get(image_id) +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + # --------------------------------------------------------------------------- # Legacy top-level component -> `image:` platform deprecation helpers # -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. return bool(config) and all( - isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. return False # A single image dict, or the grouped `defaults:`/`images:`/type-key form. return ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(merged, {})) def _add_entries(entries: object, extra: dict) -> None: # `entries` may be a single image dict or a list of them; non-dict diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index d8517d4493..9fa32a5a65 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER from esphome.components.image import ( IMAGE_TYPE, Image_, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), cv.Optional(CONF_RESIZE): cv.dimensions, cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "BIG_ENDIAN", "LITTLE_ENDIAN", upper=True - ), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(), cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), } diff --git a/esphome/config.py b/esphome/config.py index 987bb9c96a..13ec744ce4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep): elif not isinstance(self.conf, list): result[self.domain] = self.conf = [self.conf] + # Permanent expansion hook: a platform-tagged entry may expand into + # several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only. + if (expand := component.expand_platform_config) is not None and all( + isinstance(entry, dict) and CONF_PLATFORM in entry + for entry in self.conf + ): + with result.catch_error(path): + expanded = expand(self.conf) + if not isinstance(expanded, list): + # A non-list return is a component bug (not a user error): + # raise explicitly (survives -O/-OO) so it escapes catch_error. + raise TypeError( + f"{self.domain}: EXPAND_PLATFORM_CONFIG must " + f"return a list, got {type(expanded).__name__}" + ) + result[self.domain] = self.conf = expanded + # Process AUTO_LOAD _process_auto_load(result, component, path) diff --git a/esphome/loader.py b/esphome/loader.py index f994f0c5eb..23c6d1bfa5 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -164,6 +164,14 @@ class ComponentManifest: """ return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property + def expand_platform_config( + self, + ) -> Callable[[list[ConfigType]], list[ConfigType]] | None: + """Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged + entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors).""" + return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 78462463b1..846c152cab 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -21,16 +21,20 @@ from esphome.components.image import ( CONF_OPAQUE, CONF_TRANSPARENCY, PLATFORM_FILE, + _expand_platform_entry, _flatten_legacy_image_config, _is_legacy_image_format, _is_new_image_format, _migrate_legacy_image_config, + expand_platform_config, get_all_image_metadata, get_image_metadata, ) from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, + CONF_FILES, CONF_ID, CONF_PLATFORM, CONF_RAW_DATA_ID, @@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None: assert out[0][CONF_BYTE_ORDER] == "little_endian" +def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None: + """The legacy flattener drops an incompatible byte_order even when written directly on the entry.""" + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + def test_flatten_skips_meta_and_unknown_keys() -> None: out = _flatten_legacy_image_config( { @@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform( ), pytest.param({"foo": 1}, False, id="dict_unknown_keys"), pytest.param("a string", False, id="scalar"), + # A `platform:`-tagged dict is the new format written without list brackets. + pytest.param( + {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}, + False, + id="platform_tagged_flat_dict", + ), + pytest.param( + { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="platform_tagged_defaults_files_dict", + ), + # `files:` without `platform:` is not legacy either -- the flattener has no branch for it. + pytest.param( + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="defaults_files_dict_without_platform", + ), + # Same as above in a list -- without this exclusion it would be silently + # migrated to a hard-coded `platform: file` instead of raising the error. + pytest.param( + [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + } + ], + False, + id="defaults_files_list_entry_without_platform", + ), ], ) def test_is_legacy_image_format(config: object, expected: bool) -> None: @@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None: def test_migrate_returns_none_for_invalid_legacy_shapes( config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Unrecognised shapes are not migrated (and emit no warning) so normal - platform validation surfaces a proper error instead of silently dropping - the offending input.""" + """Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them.""" with caplog.at_level(logging.WARNING): assert _migrate_legacy_image_config(config) is None assert "deprecated" not in caplog.text +def test_migrate_returns_none_for_mapping_form_defaults_files() -> None: + """A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator.""" + config = { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None: + """`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has + no `files:` branch and would silently return `[]`.""" + config = { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None: + """Same, in a list -- previously the list branch migrated it to a hard-coded + `platform: file` instead of raising a missing-platform error.""" + config = [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + ] + assert _migrate_legacy_image_config(config) is None + + # --------------------------- end legacy migration -------------------------- +def test_expand_platform_entry_passes_through_plain_entry() -> None: + entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"} + assert _expand_platform_entry(0, entry) == [entry] + + +def test_expand_platform_entry_expands_files_with_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png", "type": "GRAYSCALE"}, + ], + } + assert _expand_platform_entry(0, entry) == [ + { + CONF_PLATFORM: "file", + "id": "img1", + "file": "foo.png", + "type": "RGB565", + "transparency": "opaque", + }, + { + CONF_PLATFORM: "file", + "id": "img2", + "file": "bar.png", + "type": "GRAYSCALE", + "transparency": "opaque", + }, + ] + + +def test_expand_platform_entry_files_without_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + assert _expand_platform_entry(0, entry) == [ + {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + ] + + +def test_expand_platform_entry_preserves_source_range() -> None: + """A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there.""" + from esphome import yaml_util + + file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"}) + file_entry._esp_range = "sentinel-range" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [file_entry], + } + [out] = _expand_platform_entry(0, entry) + assert isinstance(out, yaml_util.ESPHomeDataBase) + assert out.esp_range == "sentinel-range" + + +def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None: + """Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + [out] = _expand_platform_entry(0, entry) + assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + + +def test_expand_platform_entry_per_file_overrides_win() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["type"] == "BINARY" + + +def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None: + """A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"}, + CONF_FILES: [ + {"id": "a", "file": "x.png"}, + {"id": "b", "file": "y.png", "type": "binary"}, + ], + } + out = _expand_platform_entry(0, entry) + assert out[0]["byte_order"] == "little_endian" + assert "byte_order" not in out[1] + + +def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None: + """A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}], + } + with pytest.raises(cv.Invalid, match="did you mean") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "big_endian" + + +def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None: + """A `byte_order` written directly on the entry is kept so validate_settings raises the normal error.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565"}, + CONF_FILES: [ + { + "id": "a", + "file": "x.png", + "type": "binary", + "byte_order": "little_endian", + } + ], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "little_endian" + + +def test_expand_platform_entry_defaults_without_files_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}} + with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_null_files_raises_not_empty() -> None: + """A `files:` key with no value parses to `None` and must be reported clearly.""" + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None: + """An explicit `files: []` must not silently drop the whole platform entry.""" + entry = {CONF_PLATFORM: "file", CONF_FILES: []} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_with_stray_key_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png"}], + "extra": 1, + } + with pytest.raises(cv.Invalid, match="cannot be combined with"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_id_in_defaults_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_ID: "a"}, + CONF_FILES: [{"file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_defaults_raises() -> None: + """`platform:` inside `defaults:` would silently reassign every file's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_PLATFORM: "animation"}, + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_file_entry_raises() -> None: + """`platform:` on a `files:` item must not silently override the entry's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_not_list_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"} + with pytest.raises(cv.Invalid, match="must be a list"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_defaults_not_mapping_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: "not-a-mapping", + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_file_item_not_mapping_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]} + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None: + config = [ + { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png"}, + ], + }, + {CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"}, + ] + out = expand_platform_config(config) + assert [entry["id"] for entry in out] == ["img1", "img2", "plain"] + + +def test_expand_platform_config_ignores_non_platform_entries() -> None: + # Not expanded here -- legacy_config_migrate runs before this hook and is + # responsible for tagging/flattening pre-platform shapes. + config = ["not-a-platform-entry"] + assert expand_platform_config(config) == config + + +# --------------------- end defaults/files expansion ------------------------- + + def test_validate_image_final_defaults_to_little_endian() -> None: out = validate_image_final({CONF_FILE: "x.png"}) assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" diff --git a/tests/components/animation/validate-platform-defaults.host.yaml b/tests/components/animation/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..034497c548 --- /dev/null +++ b/tests/components/animation/validate-platform-defaults.host.yaml @@ -0,0 +1,21 @@ +# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: animation + defaults: + type: rgb565 + transparency: opaque + resize: 50x50 + files: + - id: platform_defaults_animation + file: $component_dir/anim.gif + - id: platform_defaults_animation_rgb + file: $component_dir/anim.apng + type: rgb diff --git a/tests/components/image/validate-platform-defaults.host.yaml b/tests/components/image/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..e1b3037cc3 --- /dev/null +++ b/tests/components/image/validate-platform-defaults.host.yaml @@ -0,0 +1,24 @@ +# `platform: file` entry using the `defaults:`/`files:` shape, including the +# per-type byte_order drop when an entry overrides to a non-endian type. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: file + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + files: + - id: platform_defaults_image + file: ../../pnglogo.png + - id: platform_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index c8b7b63094..04363ad45b 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import config, yaml_util +from esphome import config, config_validation as cv, yaml_util from esphome.core import CORE, AutoLoad from esphome.types import ConfigType @@ -127,12 +127,14 @@ def _run_load_step( domain: str, conf: object, migrate: Callable[[ConfigType], list | None] | None, + expand: Callable[[list], list] | None = None, ) -> config.Config: - """Run a LoadValidationStep for a platform component with a given migrate hook.""" + """Run a LoadValidationStep for a platform component with given hooks.""" component = Mock() component.is_platform_component = True component.multi_conf_no_default = False component.legacy_config_migrate = migrate + component.expand_platform_config = expand result = config.Config() with ( @@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None: assert result["image"] == [auto] +# --------------------------------------------------------------------------- +# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart +# to legacy_config_migrate; runs after legacy migration/list normalization. +# --------------------------------------------------------------------------- + + +def test_expand_hook_rewrites_conf() -> None: + """A config the expand hook rewrites is replaced with the expanded list.""" + expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}] + expand = Mock(return_value=expanded) + + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + expand.assert_called_once_with([{"platform": "file", "id": "a"}]) + assert result["image"] == expanded + + +def test_expand_hook_absent_is_noop() -> None: + """A platform component without the hook is left as normalized by the + existing list-wrapping logic.""" + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None) + + assert result["image"] == [{"platform": "file", "id": "a"}] + + +def test_expand_hook_runs_after_legacy_migrate() -> None: + """The expand hook sees the already-migrated list, not the raw legacy conf.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + expand = Mock(side_effect=lambda conf: conf) + + _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand) + + expand.assert_called_once_with(migrated) + + +def test_expand_hook_skipped_for_non_dict_entry() -> None: + """Malformed entries are left alone; the hook only sees `platform:`-tagged dicts.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", ["not-a-dict"], None, expand) + + expand.assert_not_called() + assert result["image"] == ["not-a-dict"] + + +def test_expand_hook_skipped_for_entry_missing_platform_key() -> None: + """A dict entry missing the `platform:` key is left alone -- the normal + per-entry error reporting further down catches this case instead.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", [{"id": "a"}], None, expand) + + expand.assert_not_called() + assert result["image"] == [{"id": "a"}] + + +def test_expand_hook_skipped_for_autoload() -> None: + """A non-empty AutoLoad reaching the hook stage is left alone.""" + expand = Mock(side_effect=lambda conf: conf) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, None, expand) + + expand.assert_not_called() + assert result["image"] == [auto] + + +def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None: + """The guard does not block the normal, well-formed case.""" + expand = Mock(side_effect=lambda conf: conf) + conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}] + + result = _run_load_step("image", conf, None, expand) + + expand.assert_called_once_with(conf) + assert result["image"] == conf + + +def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None: + """A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs.""" + expand = Mock(side_effect=cv.Invalid("bad shape")) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0].path == ["image"] + assert "bad shape" in str(result.errors[0]) + assert result["image"] == pre_expand_conf + + +def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None: + """`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended).""" + already_resolved_error = cv.FinalExternalInvalid( + "bad shape", path=["image", 3, "files"] + ) + expand = Mock(side_effect=already_resolved_error) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0] is already_resolved_error + assert result.errors[0].path == ["image", 3, "files"] + assert result["image"] == pre_expand_conf + + +def test_expand_hook_non_list_return_raises_type_error() -> None: + """A non-list return is a component bug: it escapes as an uncaught TypeError + (explicit raise survives -O/-OO).""" + expand = Mock(return_value={"not": "a list"}) + + with pytest.raises(TypeError, match="must return a list"): + _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 78a65eabdc6f33e6ac7f398a905217f61f779b64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 16:30:34 -0500 Subject: [PATCH 159/470] [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) --- .github/workflows/ci-api-proto.yml | 28 +++++++++- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 820081cc46..771b4cd94f 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -41,10 +41,32 @@ jobs: version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d8f35ed83..d2d4c7a2a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -323,7 +339,8 @@ jobs: integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # Must match seed-apt-cache's image: the apt cache key has no OS in it. + runs-on: ubuntu-24.04 needs: - common - determine-jobs @@ -335,12 +352,16 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install ccache - # Speeds up the host compiles: tests in a bucket compile overlapping - # component sets, so later tests reuse earlier tests' objects. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ccache + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -421,6 +442,7 @@ jobs: benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs @@ -457,6 +479,41 @@ jobs: fi echo "binary=$BINARY" >> $GITHUB_OUTPUT + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true + run: | + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: @@ -875,12 +932,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1415,6 +1477,7 @@ jobs: # this check. needs: - common + - seed-apt-cache - determine-jobs - ci-custom - pylint From f735dcadc0c38f25eec83cf4f5eba97bc62e30d6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:33:05 +1200 Subject: [PATCH 160/470] Bump version to 2026.8.0b6 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3dad4629be..c83d95d0ef 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.8.0b5 +PROJECT_NUMBER = 2026.8.0b6 # 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 e86465f9a0..2296f8c0b7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b5" +__version__ = "2026.8.0b6" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From c45599196235345e2f13415eccb537b2d6e13b49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 11:39:01 -0500 Subject: [PATCH 161/470] [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2d4c7a2a1..fa119fb6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -598,24 +598,29 @@ jobs: fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf From 185f12266a3f9ae0248df1a38ce96f2cc6aae7b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 162/470] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From d9359a70c1ef82ab907aba6adad45a27cd5d5fab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 163/470] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From 828eac90f36ffe9dd1fa714f41b27377fda2cafd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:51:59 +1200 Subject: [PATCH 164/470] Bump version to 2026.8.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c83d95d0ef..ed0670621d 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.8.0b6 +PROJECT_NUMBER = 2026.8.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 2296f8c0b7..17ff1e17d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b6" +__version__ = "2026.8.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ca97c86d6580746e12e071351bf3d219ef7de51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 165/470] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index daf041819c..fab6dc6ffb 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From b7d0b676fc0cd5f93a772f6f11d398a157ae612d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20H=C3=A4ll?= Date: Thu, 20 Aug 2026 06:31:27 +0200 Subject: [PATCH 166/470] [wifi] Take the lwIP core lock around sntp_servermode_dhcp() (#18511) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 245390b097..24cb060edb 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -580,7 +580,14 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - sntp_servermode_dhcp(false); + { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + // sntp_servermode_dhcp() is an empty macro unless lwIP is built with + // DHCP-supplied NTP servers, so only that build needs the core lock. + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); + } // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { From 5e9de7c94bde17b782e643a1d12745f67e23f3d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:00:44 -0500 Subject: [PATCH 167/470] Bump bundled esphome-device-builder to 1.12.1 (#18541) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18f705b501..55aa0ac982 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 RUN \ platformio settings set enable_telemetry No \ From 132f494195869750e7ccf3ad2a66f58c0234da21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 06:59:57 -0500 Subject: [PATCH 168/470] [nrf52] Rebuild the Python env when its interpreter symlink dangles (#18540) --- esphome/components/nrf52/framework.py | 26 ++++--- tests/unit_tests/test_nrf52_framework.py | 90 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 6b32fe1fea..d487820440 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -62,6 +62,22 @@ def get_sdk_nrf_tools_path() -> Path: return path.resolve() +def _needs_venv_rebuild( + env_python_path: Path, sentinel: Path, requirements_hash: str +) -> bool: + """True when a penv must be (re)built. + + Rebuild when the interpreter is not a regular file, which covers a + dangling symlink (a cached venv outliving a host interpreter upgrade) + and a corrupt restore, or when the sentinel is missing or stale. + """ + return ( + not env_python_path.is_file() + or not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ) + + def _get_python_env_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "penvs" / version @@ -198,10 +214,7 @@ def setup_platformio_python_env() -> None: + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() ).hexdigest() - if ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ): + if _needs_venv_rebuild(env_python_path, sentinel, requirements_hash): rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") create_venv(penv_path, msg="PlatformIO toolchain") @@ -250,10 +263,7 @@ def check_and_install() -> None: env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() - install_venv = ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ) + install_venv = _needs_venv_rebuild(env_python_path, sentinel, requirements_hash) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 0a6bddc280..c2ee0c2a75 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -16,6 +16,7 @@ from esphome.components.nrf52.framework import ( _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, + _needs_venv_rebuild, check_and_install, get_build_env, get_sdk_nrf_tools_path, @@ -123,10 +124,19 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _touch_penv_python(penv: Path) -> None: + """Create the interpreter file so the rebuild gate sees a live venv.""" + python = get_python_env_executable_path(penv, "python") + python.parent.mkdir(parents=True, exist_ok=True) + python.touch() + + def _mark_venv_ready(python_env: Path) -> None: - """Write the venv sentinel with the current requirements hash.""" + """Write the venv sentinel with the current requirements hash and a + present interpreter so the rebuild gate passes.""" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + _touch_penv_python(python_env) class TestCheckAndInstall: @@ -148,6 +158,23 @@ class TestCheckAndInstall: mock_nrf52_ops.download_from_mirrors.assert_not_called() mock_nrf52_ops.archive_extract_all.assert_not_called() + def test_missing_interpreter_rebuilds_venv( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter (a cached venv + restored after a host interpreter upgrade).""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (nrf52_dirs.python_env / ".ready").write_text( + requirements_hash, encoding="utf-8" + ) + # no interpreter on disk + + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_fresh_install_runs_all_steps( self, nrf52_dirs: SimpleNamespace, @@ -348,6 +375,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) with patch.dict(os.environ): setup_platformio_python_env() @@ -392,6 +420,22 @@ class TestSetupPlatformioPythonEnv: assert not (platformio_penv_dir / ".ready").exists() + def test_missing_interpreter_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + # no interpreter on disk + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_repeated_calls_do_not_duplicate_env_entries( self, platformio_penv_dir: Path, @@ -401,6 +445,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) bin_dir = str( get_python_env_executable_path(platformio_penv_dir, "python").parent @@ -422,6 +467,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): @@ -531,3 +577,45 @@ def testget_tools_path_default_is_global_cache( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" ).resolve() assert get_sdk_nrf_tools_path() == expected + + +def test_needs_venv_rebuild_gates(tmp_path: Path) -> None: + """The shared penv gate rebuilds on any missing or stale piece.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + good_hash = "abc123" + + # Nothing in place yet + assert _needs_venv_rebuild(python, sentinel, good_hash) + + python.write_text("") + # Interpreter present but no sentinel + assert _needs_venv_rebuild(python, sentinel, good_hash) + + sentinel.write_text(good_hash, encoding="utf-8") + # Everything in place + assert not _needs_venv_rebuild(python, sentinel, good_hash) + + # Stale requirements hash + assert _needs_venv_rebuild(python, sentinel, "otherhash") + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlink creation needs privileges on Windows" +) +def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> None: + """A cached venv restored after a host interpreter upgrade has a + bin/python symlink whose target is gone; the valid sentinel must not + mask it.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + sentinel.write_text("abc123", encoding="utf-8") + python.symlink_to(tmp_path / "hostedtoolcache" / "3.12.14" / "python3") + assert python.is_symlink() + assert not python.exists() + + assert _needs_venv_rebuild(python, sentinel, "abc123") From aafeca585920d39990457e46fec68faf8d4ae2d8 Mon Sep 17 00:00:00 2001 From: Alar Aun Date: Thu, 20 Aug 2026 16:54:28 +0300 Subject: [PATCH 169/470] [modbus_controller] Brace single-statement log bodies to fix -Wempty-body (#18543) --- esphome/components/modbus_controller/modbus_controller.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 2c568938e4..515459f62a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -200,8 +200,9 @@ void ModbusController::update_range_(ModbusCommandItem &cmd) { return; } // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); + } } void ModbusController::update() { @@ -214,8 +215,9 @@ void ModbusController::update() { ESP_LOGV(TAG, "Module offline - retrying"); this->cmd_non_responses_ = 0; // allow the probe through can_send() for (auto &cmd : this->polling_command_items_) { - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); + } } } else { ESP_LOGV(TAG, "Module offline - skipping update"); From 3c47ab42d63b53026dcfa611baca05d08b79e3ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:55:15 +1200 Subject: [PATCH 170/470] [core] Add type annotations to component Python (1/11) (#18338) --- esphome/components/bl0906/sensor.py | 12 +++++-- esphome/components/datetime/__init__.py | 36 +++++++++++++------ esphome/components/esp32_ble/__init__.py | 30 ++++++++++++---- esphome/components/esp32_rmt/__init__.py | 14 +++++--- esphome/components/espnow/__init__.py | 27 ++++++++------ .../espnow/packet_transport/__init__.py | 3 +- esphome/components/http_request/__init__.py | 20 +++++++---- .../components/http_request/ota/__init__.py | 13 +++++-- .../http_request/update/__init__.py | 3 +- esphome/components/i2s_audio/__init__.py | 13 +++---- .../i2s_audio/microphone/__init__.py | 13 +++---- .../components/i2s_audio/speaker/__init__.py | 13 +++---- esphome/components/mcp23xxx_base/__init__.py | 8 +++-- esphome/components/mcp4461/__init__.py | 3 +- esphome/components/mcp4461/output/__init__.py | 28 ++++++++++++--- esphome/components/microphone/__init__.py | 31 ++++++++++------ esphome/components/pn532/__init__.py | 14 ++++++-- esphome/components/pn532/binary_sensor.py | 7 ++-- esphome/components/pn7150/__init__.py | 26 +++++++++++--- esphome/components/pn7160/__init__.py | 26 +++++++++++--- 20 files changed, 245 insertions(+), 95 deletions(-) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 059e10e962..1a0c2287ab 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,6 +32,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py from .const import ICON_ENERGY, ICON_FREQUENCY, ICON_VOLTAGE @@ -145,13 +148,18 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/datetime/__init__.py b/esphome/components/datetime/__init__.py index 87997daa3d..f8b6446006 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -21,13 +21,14 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -65,7 +66,7 @@ DATETIME_MODES = [ ] -def _validate_time_present(config): +def _validate_time_present(config: ConfigType) -> ConfigType: config = config.copy() if CONF_ON_TIME in config and CONF_TIME_ID not in config: time_id = cv.use_id(time.RealTimeClock)(None) @@ -139,7 +140,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: @setup_entity("datetime") -async def setup_datetime_core_(var, config): +async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) @@ -160,7 +161,7 @@ async def setup_datetime_core_(var, config): await cg.register_parented(trigger, var) -async def register_datetime(var, config): +async def register_datetime(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() @@ -169,14 +170,14 @@ async def register_datetime(var, config): await setup_datetime_core_(var, config) -async def new_datetime(config, *args): +async def new_datetime(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_datetime(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) @@ -193,7 +194,12 @@ async def to_code(config): ), synchronous=True, ) -async def datetime_date_set_to_code(config, action_id, template_arg, args): +async def datetime_date_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -226,7 +232,12 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_time_set_to_code(config, action_id, template_arg, args): +async def datetime_time_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -259,7 +270,12 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_datetime_set_to_code(config, action_id, template_arg, args): +async def datetime_datetime_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f099c68e57..79747c6f31 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -31,7 +31,8 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, ID, TimePeriod +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -383,7 +384,7 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -def validate_variant(_): +def validate_variant(_: ConfigType) -> None: variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: raise cv.Invalid(f"{variant} does not support Bluetooth") @@ -443,7 +444,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config) -> None: +def final_validation(config: ConfigType) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -518,7 +519,7 @@ def final_validation(config) -> None: FINAL_VALIDATE_SCHEMA = final_validation -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) @@ -605,19 +606,34 @@ async def to_code(config): @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code(config, condition_id, template_arg, args): +async def ble_enabled_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(condition_id, template_arg) @automation.register_action( "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True ) -async def ble_enable_to_code(config, action_id, template_arg, args): +async def ble_enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) @automation.register_action( "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True ) -async def ble_disable_to_code(config, action_id, template_arg, args): +async def ble_disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1076bcabdc..a213a78778 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -1,17 +1,23 @@ +from collections.abc import Callable, Iterable +from typing import Any + from esphome.components import esp32 import esphome.config_validation as cv from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} -def validate_rmt_not_supported(rmt_only_keys): +def validate_rmt_not_supported( + rmt_only_keys: Iterable[str], +) -> Callable[[ConfigType], ConfigType]: """Validate that RMT-only config keys are not used on variants without RMT hardware.""" rmt_only_keys = set(rmt_only_keys) - def _validator(config): + def _validator(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in VARIANTS_NO_RMT: @@ -26,8 +32,8 @@ def validate_rmt_not_supported(rmt_only_keys): return _validator -def validate_clock_resolution(): - def _validator(value): +def validate_clock_resolution() -> Callable[[Any], int]: + def _validator(value: Any) -> int: cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 373ef345d1..ee3732c406 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi @@ -14,6 +16,7 @@ from esphome.const import ( CONF_WIFI, ) from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -78,7 +81,7 @@ CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -def _validate_max_payload_size(value: int) -> int: +def _validate_max_payload_size(value: Any) -> int: if value > ESPNOW_PAYLOAD_V1: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 0), @@ -88,7 +91,7 @@ def _validate_max_payload_size(value: int) -> int: return value -def validate_channel(value): +def validate_channel(value: Any) -> int: if value is None: raise cv.Invalid("channel is required if wifi is not configured") return wifi.validate_channel(value) @@ -129,7 +132,7 @@ CONFIG_SCHEMA = cv.All( ) -async def _trigger_to_code(config): +async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) @@ -145,7 +148,7 @@ async def _trigger_to_code(config): return trigger -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -180,13 +183,13 @@ async def to_code(config): # ========================================== A C T I O N S ================================================ -def validate_peer(value): +def validate_peer(value: Any) -> Any: if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return cv.mac_address(value) -def _validate_raw_data(value): +def _validate_raw_data(value: Any) -> str | list: if isinstance(value, str): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( @@ -204,7 +207,9 @@ def _validate_raw_data(value): ) -async def register_peer(var, config, args): +async def register_peer( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): peer = [HexInt(p) for p in peer.parts] @@ -231,7 +236,7 @@ SEND_SCHEMA = PEER_SCHEMA.extend( ) -def _validate_send_action(config): +def _validate_send_action(config: ConfigType) -> ConfigType: if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: raise cv.Invalid( f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", @@ -267,7 +272,7 @@ async def send_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -316,7 +321,7 @@ async def peer_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) await register_peer(var, config, args) @@ -341,7 +346,7 @@ async def channel_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) diff --git a/esphome/components/espnow/packet_transport/__init__.py b/esphome/components/espnow/packet_transport/__init__.py index e6d66440db..ee4706ca1c 100644 --- a/esphome/components/espnow/packet_transport/__init__.py +++ b/esphome/components/espnow/packet_transport/__init__.py @@ -9,6 +9,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.core import HexInt from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import ESPNowComponent, espnow_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = transport_schema(ESPNowTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: """Set up the ESP-NOW transport component.""" var, _ = await new_packet_transport(config) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 54d7f5c77b..afc39e06a8 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any from esphome import automation import esphome.codegen as cg @@ -20,8 +21,10 @@ from esphome.const import ( PlatformFramework, __version__, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.helpers import IS_MACOS +from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] @@ -63,14 +66,14 @@ CONF_BODY = "body" CONF_JSON = "json" -def validate_url(value): +def validate_url(value: Any) -> str: value = cv.url(value) if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") -def validate_ssl_verification(config): +def validate_ssl_verification(config: ConfigType) -> ConfigType: error_message = "" if CORE.is_rp2 and config[CONF_VERIFY_SSL]: @@ -91,7 +94,7 @@ def validate_ssl_verification(config): return config -def _declare_request_class(value): +def _declare_request_class(value: Any) -> ID: if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: @@ -151,7 +154,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_useragent(config[CONF_USERAGENT])) @@ -298,7 +301,12 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( HTTP_REQUEST_SEND_ACTION_SCHEMA, synchronous=True, ) -async def http_request_action_to_code(config, action_id, template_arg, args): +async def http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index b7026e0f55..784e4ee47a 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,8 +3,10 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import coroutine_with_priority +from esphome.core import ID, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -42,7 +44,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) @@ -72,7 +74,12 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, synchronous=True, ) -async def ota_http_request_action_to_code(config, action_id, template_arg, args): +async def ota_http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index d84d80109a..4bdc30e4cf 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns from ..ota import OtaHttpRequestComponent @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await update.new_update(config) ota_parent = await cg.get_variable(config[CONF_OTA_ID]) cg.add(var.set_ota_parent(ota_parent)) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 4809bf5a92..c5e82beb46 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -21,8 +21,9 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -145,7 +146,7 @@ I2S_MCLK_MULTIPLE = { _validate_bits = cv.float_with_unit("bits", "bit") -def validate_mclk_divisible_by_3(config): +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: raise cv.Invalid( f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" @@ -159,7 +160,7 @@ def i2s_audio_component_schema( default_sample_rate: int, default_channel: str, default_bits_per_sample: str, -): +) -> cv.Schema: return cv.Schema( { cv.GenerateID(): cv.declare_id(class_), @@ -182,7 +183,7 @@ def i2s_audio_component_schema( ) -async def register_i2s_audio_component(var, config): +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] @@ -260,7 +261,7 @@ def _assign_ports() -> None: next_port += 1 -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -275,7 +276,7 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 9c6228087c..c217317237 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, ) +from esphome.types import ConfigType from .. import ( CONF_ADC_TYPE, @@ -46,7 +47,7 @@ I2S_PDM_DSR = { } -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_ADC_TYPE] == "external": if config[CONF_PDM] and variant not in PDM_VARIANTS: @@ -65,13 +66,13 @@ def _validate_esp32_variant(config): raise NotImplementedError -def _validate_channel(config): +def _validate_channel(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] == CONF_MONO: raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") return config -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -80,7 +81,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), @@ -134,7 +135,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_ADC_TYPE] == "internal": raise cv.Invalid( "Internal ADC is no longer supported. Use an external I2S microphone instead." @@ -144,7 +145,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 6d3c39c68e..1849c376aa 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +from esphome.types import ConfigType from .. import ( CONF_I2S_DOUT_PIN, @@ -78,7 +79,7 @@ I2C_COMM_FMT_OPTIONS = { INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -87,7 +88,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: if config.get(CONF_SPDIF_MODE, False): # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( @@ -133,14 +134,14 @@ def _set_stream_limits(config): return config -def _select_speaker_class(config): +def _select_speaker_class(config: ConfigType) -> ConfigType: """Override ID type when SPDIF mode is enabled.""" if config.get(CONF_SPDIF_MODE, False): config[CONF_ID].type = I2SAudioSpeakerSPDIF return config -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: @@ -207,7 +208,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_DAC_TYPE] == "internal": raise cv.Invalid( "Internal DAC is no longer supported. Use an external I2S DAC instead." @@ -238,7 +239,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d53499a78f..755d86e4ea 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID, coroutine +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] @@ -41,7 +43,7 @@ MCP23XXX_CONFIG_SCHEMA = cv.Schema( @coroutine -async def register_mcp23xxx(config, num_pins): +async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: id: ID = config[CONF_ID] var = cg.new_Pvariable(id) await cg.register_component(var, config) @@ -52,7 +54,7 @@ async def register_mcp23xxx(config, num_pins): return var -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -81,7 +83,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) -async def mcp23xxx_pin_to_code(config): +async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] parent = await cg.get_variable(parent_id) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index f3ef6f4917..60cece67d7 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_DISABLE_WIPER_0], diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 99d4988c90..db1a1e6a29 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -34,7 +37,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config) -> None: +def _validate_nonvolatile(config: ConfigType) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -89,7 +92,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( FINAL_VALIDATE_SCHEMA = _validate_nonvolatile -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MCP4461_ID]) var = cg.new_Pvariable( config[CONF_ID], @@ -147,7 +150,12 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( @automation.register_action( "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True ) -async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_step_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -158,7 +166,12 @@ async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): WIPER_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_store_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -169,7 +182,12 @@ async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): TERMINAL_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_terminal_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable( action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6b5ee8c3e1..9a3f5b43e7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,8 +14,10 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -50,7 +54,7 @@ IsCapturingCondition = microphone_ns.class_( IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) -async def setup_microphone_core_(var, config): +async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation( @@ -60,7 +64,7 @@ async def setup_microphone_core_(var, config): ) -async def register_microphone(var, config): +async def register_microphone(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) await setup_microphone_core_(var, config) @@ -85,7 +89,7 @@ def microphone_source_schema( max_bits_per_sample: int = 16, min_channels: int = 1, max_channels: int = 1, -): +) -> cv.All: """Schema for a microphone source Components requesting microphone data should use this schema instead of accessing a microphone directly. @@ -97,7 +101,7 @@ def microphone_source_schema( max_channels (int, optional): Maximum number of channels the requesting component supports. Defaults to 1. """ - def _validate_unique_channels(config): + def _validate_unique_channels(config: list[int]) -> list[int]: if len(config) != len(set(config)): raise cv.Invalid("Channels must be unique") return config @@ -124,7 +128,7 @@ def microphone_source_schema( def final_validate_microphone_source_schema( component_name: str, sample_rate: int = cv.UNDEFINED -): +) -> Callable[[ConfigType], ConfigType]: """Validates that the microphone source can provide audio in the correct format. In particular it validates the sample rate and the enabled channels. Note that: @@ -136,7 +140,7 @@ def final_validate_microphone_source_schema( sample_rate (int, optional): The sample rate the component requesting mic audio requires """ - def _validate_audio_compatability(config): + def _validate_audio_compatability(config: ConfigType) -> ConfigType: if sample_rate is not cv.UNDEFINED: # Issues require changing the microphone configuration # - Verifies sample rates match @@ -161,7 +165,9 @@ def final_validate_microphone_source_schema( return _validate_audio_compatability -async def microphone_source_to_code(config, passive=False): +async def microphone_source_to_code( + config: ConfigType, passive: bool = False +) -> MockObj: """Creates a MicrophoneSource variable for codegen. Setting passive to true makes the MicrophoneSource never start/stop the microphone, but only receives audio when another component has actively started the Microphone. If false, then the microphone needs to be explicitly started/stopped. @@ -183,7 +189,12 @@ async def microphone_source_to_code(config, passive=False): return mic_source -async def microphone_action(config, action_id, template_arg, args): +async def microphone_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -219,6 +230,6 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(microphone_ns.using) cg.add_define("USE_MICROPHONE") diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index f34df21647..6258932312 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] AUTO_LOAD = ["binary_sensor", "nfc"] @@ -41,7 +44,7 @@ PN532_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -def CONFIG_SCHEMA(conf): +def CONFIG_SCHEMA(conf: ConfigType) -> None: if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " @@ -56,7 +59,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn532(var, config): +async def setup_pn532(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) for conf in config.get(CONF_ON_TAG, []): @@ -85,7 +88,12 @@ async def setup_pn532(var, config): } ), ) -async def pn532_is_writing_to_code(config, condition_id, template_arg, args): +async def pn532_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index b9c3103c65..8f490ba7d0 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_PN532_ID, PN532, pn532_ns DEPENDENCIES = ["pn532"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 9dd3e8c5b0..4638992abf 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -107,7 +110,12 @@ PN7150_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_set_message_to_code(config, action_id, template_arg, args): +async def pn7150_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -158,7 +166,12 @@ async def pn7150_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_simple_action_to_code(config, action_id, template_arg, args): +async def pn7150_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -174,7 +187,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7150(var, config): +async def setup_pn7150(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) @@ -216,7 +229,12 @@ async def setup_pn7150(var, config): } ), ) -async def pn7150_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7150_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index ef14a29099..7f9f9172a1 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -111,7 +114,12 @@ PN7160_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_set_message_to_code(config, action_id, template_arg, args): +async def pn7160_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -162,7 +170,12 @@ async def pn7160_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_simple_action_to_code(config, action_id, template_arg, args): +async def pn7160_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -178,7 +191,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7160(var, config): +async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): @@ -228,7 +241,12 @@ async def setup_pn7160(var, config): } ), ) -async def pn7160_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7160_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var From 8ef0f38f4efff4974bb1e96a210e128d99feb2fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 08:59:30 -0500 Subject: [PATCH 171/470] [ethernet] Skip the custom W5500 SPI driver for other ethernet types (#18533) --- esphome/components/ethernet/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 5eda0fc12c..7686b64cb4 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -811,6 +811,10 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, + "w5500_custom_spi.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, } ) @@ -830,6 +834,11 @@ def _filter_source_files() -> list[str]: # to avoid shadowing. Native IDF builds always need the custom driver. if cv.Version(5, 4, 2) <= idf_version() < cv.Version(6, 0, 0): excluded.append("esp_eth_phy_jl1101.c") + # The custom W5500 SPI driver is fully #ifdef'd on USE_ESP32 and + # USE_ETHERNET_W5500 (the platform filter map above handles non-ESP32); + # skip it entirely for the other ethernet types. + if eth_type != "W5500": + excluded.append("w5500_custom_spi.cpp") return excluded From 2d62ea78d203727c0f20a824add2b78271f33d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:00:11 -0500 Subject: [PATCH 172/470] [ota] Skip partition-access OTA sources when the feature is disabled (#18532) --- esphome/components/ota/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 2d4de52e8f..1e2ee947c1 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -182,4 +182,11 @@ def FILTER_SOURCE_FILES() -> list[str]: for define in CORE.defines ): files.append("ota_signature_esp_idf.cpp") + # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully + # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when + # allow_partition_access is enabled). Filter them out otherwise for the + # same reason as above. + if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): + files.append("ota_bootloader_esp_idf.cpp") + files.append("ota_partitions_esp_idf.cpp") return files From a8e721abebdda3a42b1b6ecf391a90938b2187ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:03:49 -0500 Subject: [PATCH 173/470] [esp32] Apply IDF component exclusions to native toolchain builds (#18531) --- esphome/build_gen/espidf.py | 41 +++++++- esphome/components/esp32/__init__.py | 19 ++-- esphome/espidf/toolchain.py | 24 ++++- tests/unit_tests/build_gen/test_espidf.py | 115 +++++++++++++++++++--- tests/unit_tests/test_espidf_toolchain.py | 69 +++++++++++++ 5 files changed, 243 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cf476555e7..b65ce23307 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,12 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant, idf_version +from esphome.components.esp32 import ( + get_esp32_variant, + get_excluded_builtin_components, + get_managed_component_require_names, + idf_version, +) import esphome.config_validation as cv from esphome.core import CORE from esphome.framework_helpers import ( @@ -119,24 +124,40 @@ def get_project_cmakelists(minimal: bool = False) -> str: # runs as a separate CMake script invocation that doesn't load the # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_ # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty). - from esphome.components.esp32 import get_managed_component_require_names - managed_components_property = "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" for name in get_managed_component_require_names() ) + # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS + # minus per-component re-includes). project.cmake reads the plain + # EXCLUDE_COMPONENTS variable when seeding the component list, so this + # must be set before project(). Emitted on minimal writes too so the + # discovery reconfigure never registers the excluded components. + excluded_components = get_excluded_builtin_components() + exclude_components_var = ( + f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' + if excluded_components + else "" + ) + # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. + # Excluded components are dropped here as well: a stale + # project_description.json from a build without exclusions may still + # list them, and requiring an excluded component pulls it back into + # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" - for name in sorted(get_available_components() or []) + for name in sorted( + set(get_available_components() or []).difference(excluded_components) + ) ) ) @@ -165,6 +186,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{exclude_components_var} + {cpp_standard_options} {cxx_compile_options} @@ -264,3 +287,13 @@ def write_project(minimal: bool = False) -> None: CORE.relative_src_path("CMakeLists.txt"), get_component_cmakelists(), ) + + # Snapshot the exclusion set so has_outdated_files() can trigger a + # discovery reconfigure when it changes. Excluded components never + # register in project_description.json, so re-including one (e.g. a + # config gains mqtt) requires a fresh discovery pass before the + # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + write_file_if_changed( + CORE.relative_build_path("exclude_components.esphomeinternal"), + ";".join(get_excluded_builtin_components()), + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3065cdadad..d6e0890751 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -738,6 +738,16 @@ def include_builtin_idf_component(name: str) -> None: CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) +def get_excluded_builtin_components() -> list[str]: + """Return the sorted built-in IDF components excluded from the build. + + Single accessor for both build writers: the PlatformIO path passes it as + ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the + generated CMakeLists. + """ + return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) + + def _enable_arduino_library(name: str) -> None: """Enable an Arduino library that is disabled by default. @@ -2122,13 +2132,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if KEY_ESP32 not in CORE.data: - return - excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) - if excluded: - exclude_list = ";".join(sorted(excluded)) + if excluded := get_excluded_builtin_components(): cg.add_platformio_option( - "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" + "board_build.cmake_extra_args", + f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index bb6452acf2..07ba03e2cf 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -273,6 +273,11 @@ def has_outdated_files(): happen without any sdkconfig impact, and ``_write_idf_component_yml`` already deletes ``dependencies.lock`` on a change but that signal gets lost as soon as the lock is missing. + - ``exclude_components.esphomeinternal`` -- the resolved + EXCLUDE_COMPONENTS set. Excluded components never register in + ``project_description.json``, so re-including one needs a fresh + discovery pass before it can appear in the builtin-components + property that ``src`` REQUIRES. We deliberately don't watch: - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and @@ -291,6 +296,9 @@ def has_outdated_files(): f"sdkconfig.{CORE.name}.esphomeinternal" ) idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") + exclude_components_path = CORE.relative_build_path( + "exclude_components.esphomeinternal" + ) dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -309,7 +317,11 @@ def has_outdated_files(): cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( f.stat().st_mtime > cmakecache_txt_mtime - for f in [sdkconfig_internal_path, idf_component_yml_path] + for f in [ + sdkconfig_internal_path, + idf_component_yml_path, + exclude_components_path, + ] if f.exists() ) @@ -386,6 +398,16 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) + # Restamp the reference file has_outdated_files() compares against. + # A reconfigure that only changes properties or plain variables + # (sdkconfig options, the exclusion set) does not rewrite + # CMakeCache.txt, so without this the watched inputs stay newer + # forever and every subsequent build repeats the discovery pass. + # Done after the full write so an interrupt cannot leave a minimal + # CMakeLists behind that is already marked fresh. + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + if cmakecache.is_file(): + os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index f21549b48c..ec01000920 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,6 +11,7 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_IDF_VERSION, KEY_PATH, KEY_REF, @@ -28,6 +29,7 @@ def _reset_core(tmp_path: Path) -> None: CORE.data.setdefault(KEY_CORE, {}) CORE.data[KEY_ESP32] = { KEY_COMPONENTS: {}, + KEY_EXCLUDE_COMPONENTS: set(), KEY_IDF_VERSION: cv.Version(5, 5, 4), } @@ -47,6 +49,17 @@ def _write_project_description(tmp_path: Path, components: dict[str, str]) -> No ) +def _render(minimal: bool = False) -> str: + """Render the top-level CMakeLists with the standard variant/name patches.""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + return get_project_cmakelists(minimal=minimal) + + def test_get_available_components_returns_none_without_build_path() -> None: """No build_path set yet: must not raise on Path(None).""" CORE.build_path = None @@ -88,13 +101,7 @@ def test_get_project_cmakelists_minimal_omits_builtin_components_property( first write before the discovery pass refreshes it).""" _write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"}) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=True) + content = _render(minimal=True) assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content @@ -115,13 +122,7 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( }, ) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=False) + content = _render() assert ( "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)" @@ -136,6 +137,92 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: + """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are + dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale + project_description.json still lists them (requiring an excluded + component would pull it back into the build).""" + _write_project_description( + tmp_path, + { + "esp_lcd": "/idf/components/esp_lcd", + "freertos": "/idf/components/freertos", + "unity": "/idf/components/unity", + }, + ) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "esp_lcd;unity")' in content + # Must be set before project() so project.cmake sees it. + assert content.index("set(EXCLUDE_COMPONENTS") < content.index("project(test)") + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)" + in content + ) + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS unity" not in content + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content + + +def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: + """The discovery (minimal) write also excludes components so they never + register in project_description.json.""" + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + + content = _render(minimal=True) + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + + +def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: + """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + content = _render() + + assert "EXCLUDE_COMPONENTS" not in content + + +def test_include_builtin_idf_component_removes_exclusion() -> None: + """include_builtin_idf_component() drops a name from the exclusion set so + a component a config actually uses is not passed to EXCLUDE_COMPONENTS.""" + from esphome.components.esp32 import ( + exclude_builtin_idf_component, + get_excluded_builtin_components, + include_builtin_idf_component, + ) + + exclude_builtin_idf_component("esp_eth") + exclude_builtin_idf_component("unity") + include_builtin_idf_component("esp_eth") + + assert get_excluded_builtin_components() == ["unity"] + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + assert "esp_eth" not in content + + +def test_write_project_writes_exclude_components_stamp(tmp_path: Path) -> None: + """write_project() snapshots the exclusion set; the toolchain watches the + stamp to trigger a discovery reconfigure when the set changes (excluded + components never register in project_description.json).""" + CORE.build_flags = set() + CORE.build_path = tmp_path + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import write_project + + write_project() + + stamp = tmp_path / "exclude_components.esphomeinternal" + assert stamp.read_text() == "esp_lcd;unity" + + 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() diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 26d812af8b..2556397aef 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -100,6 +100,33 @@ def _setup_build(setup_core: Path) -> tuple[Path, Path]: return compile_commands, cache +def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None: + """A newer exclude_components.esphomeinternal stamp forces a reconfigure + so components that leave the exclusion set get rediscovered.""" + CORE.build_path = setup_core + build = setup_core / "build" + (build / "config").mkdir(parents=True) + (build / "config" / "sdkconfig.h").write_text("") + cmakecache = build / "CMakeCache.txt" + cmakecache.write_text("") + (build / "build.ninja").write_text("") + + with patch.object(CORE, "name", "test"): + assert not toolchain.has_outdated_files() + + stamp = setup_core / "exclude_components.esphomeinternal" + stamp.write_text("unity") + os.utime(stamp, (cmakecache.stat().st_mtime + 10,) * 2) + + assert toolchain.has_outdated_files() + + # The flag must clear once the reference file is restamped (as + # run_compile does after a successful discovery reconfigure); + # otherwise every later build would repeat the discovery pass. + os.utime(cmakecache, (stamp.stat().st_mtime + 10,) * 2) + assert not toolchain.has_outdated_files() + + 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) @@ -373,6 +400,48 @@ def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: assert "IDF_PY_BUILD_JOBS" not in env +def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> None: + """After a successful discovery reconfigure the reference CMakeCache.txt + is restamped; cmake does not rewrite it when only properties or plain + variables change, so the staleness flag would otherwise never clear.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + cmakecache.parent.mkdir(parents=True, exist_ok=True) + cmakecache.write_text("") + old = cmakecache.stat().st_mtime - 100 + os.utime(cmakecache, (old, old)) + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert cmakecache.stat().st_mtime > old + + +def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: + """A discovery pass that produced no CMakeCache.txt (nothing to restamp) + still completes normally.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert not CORE.relative_build_path("build/CMakeCache.txt").exists() + + def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: """compile_process_limit is forwarded to run_idf_py as the job limit.""" _setup_build(setup_core) From 347a6155f8783342d1bb7da05ad4a1254fe47f45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:07:17 -0500 Subject: [PATCH 174/470] [uptime] Skip the timestamp sensor source when no time component is configured (#18535) --- esphome/components/uptime/sensor/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index e2a7aee1a2..debeb41444 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import CORE uptime_ns = cg.esphome_ns.namespace("uptime") UptimeSecondsSensor = uptime_ns.class_( @@ -59,3 +60,11 @@ async def to_code(config): if time_id_config := config.get(CONF_TIME_ID): time_id = await cg.get_variable(time_id_config) cg.add(var.set_time(time_id)) + + +def FILTER_SOURCE_FILES() -> list[str]: + # uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it + # when no time component is configured. + if not any(define.name == "USE_TIME" for define in CORE.defines): + return ["uptime_timestamp_sensor.cpp"] + return [] From ecca240eef2b79baa85d3eca6665a333e11c0e62 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:26:16 +1200 Subject: [PATCH 175/470] [core] Add type annotations to component Python (2/11) (#18339) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/cm1106/sensor.py | 12 +++++-- esphome/components/esp_ldo/__init__.py | 22 +++++++++---- esphome/components/ili9xxx/display.py | 13 ++++---- esphome/components/it8951/display.py | 32 +++++++++++++------ esphome/components/mapping/__init__.py | 17 ++++++---- esphome/components/mipi_dsi/display.py | 9 +++--- esphome/components/mipi_rgb/display.py | 14 ++++---- esphome/components/mipi_rgb/models/st7701s.py | 2 +- esphome/components/mipi_spi/display.py | 15 +++++---- esphome/components/online_image/image.py | 10 ++++-- .../components/packet_transport/__init__.py | 27 +++++++++------- .../packet_transport/binary_sensor.py | 5 +-- esphome/components/packet_transport/sensor.py | 3 +- esphome/components/pca9554/__init__.py | 12 ++++--- esphome/components/qspi_dbi/display.py | 16 ++++++---- esphome/components/qspi_dbi/models.py | 10 +++--- esphome/components/rpi_dpi_rgb/display.py | 9 ++++-- esphome/components/sdl/binary_sensor.py | 3 +- esphome/components/sdl/display.py | 9 ++++-- .../components/sdl/touchscreen/__init__.py | 3 +- esphome/components/seeed_mr24hpc1/__init__.py | 3 +- .../seeed_mr24hpc1/binary_sensor.py | 3 +- .../seeed_mr24hpc1/button/__init__.py | 3 +- .../seeed_mr24hpc1/number/__init__.py | 3 +- .../seeed_mr24hpc1/select/__init__.py | 3 +- esphome/components/seeed_mr24hpc1/sensor.py | 3 +- .../seeed_mr24hpc1/switch/__init__.py | 3 +- .../components/seeed_mr24hpc1/text_sensor.py | 3 +- esphome/components/seeed_mr60bha2/__init__.py | 3 +- .../seeed_mr60bha2/binary_sensor.py | 3 +- esphome/components/seeed_mr60bha2/sensor.py | 3 +- esphome/components/seeed_mr60fda2/__init__.py | 3 +- .../seeed_mr60fda2/binary_sensor.py | 3 +- .../seeed_mr60fda2/button/__init__.py | 3 +- .../seeed_mr60fda2/select/__init__.py | 3 +- esphome/components/st7701s/display.py | 11 ++++--- esphome/components/st7701s/init_sequences.py | 2 +- esphome/components/udp/__init__.py | 22 +++++++++---- .../udp/packet_transport/__init__.py | 3 +- esphome/components/usb_uart/__init__.py | 19 +++++------ 40 files changed, 220 insertions(+), 125 deletions(-) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 3c82fac977..936c5fc673 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] @@ -44,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: """Code generation entry point.""" var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -67,7 +70,12 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: +async def cm1106_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: """Service code generation entry point.""" paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index a489651b59..46810d422d 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome.automation import Action, register_action import esphome.codegen as cg from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_VOLTAGE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -22,7 +27,7 @@ CONF_PASSTHROUGH = "passthrough" adjusted_ids = set() -def validate_ldo_voltage(value): +def validate_ldo_voltage(value: Any) -> str | float: if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH: return CONF_PASSTHROUGH value = cv.voltage(value) @@ -33,7 +38,7 @@ def validate_ldo_voltage(value): ) -def validate_ldo_config(config): +def validate_ldo_config(config: ConfigType) -> ConfigType: channel = config[CONF_CHANNEL] allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL] if allow_internal and channel not in CHANNELS_INTERNAL: @@ -77,7 +82,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: for config in configs: var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL]) await cg.register_component(var, config) @@ -89,7 +94,7 @@ async def to_code(configs): cg.add(var.set_adjustable(config[CONF_ADJUSTABLE])) -def final_validate(configs): +def final_validate(configs: list[ConfigType]) -> None: for channel in CHANNELS: used = [config for config in configs if config[CONF_CHANNEL] == channel] if len(used) > 1: @@ -112,7 +117,7 @@ def final_validate(configs): FINAL_VALIDATE_SCHEMA = final_validate -def adjusted_ldo_id(value): +def adjusted_ldo_id(value: Any) -> ID: value = cv.use_id(EspLdo)(value) adjusted_ids.add(value) return value @@ -131,7 +136,12 @@ def adjusted_ldo_id(value): ), synchronous=True, ) -async def ldo_voltage_adjust_to_code(config, action_id, template_arg, args): +async def ldo_voltage_adjust_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) template_ = await cg.templatable(config[CONF_VOLTAGE], args, cg.float_) diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index b1d332c1e5..64f87c167c 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -31,6 +31,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.final_validate import full_config +from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -91,7 +92,7 @@ CONF_INVERT_DISPLAY = "invert_display" CONF_PIXEL_MODE = "pixel_mode" -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) @@ -101,7 +102,7 @@ def cmd(c, *args): return [c, len(args)] + list(args) -def map_sequence(value): +def map_sequence(value: list[int]) -> list[int]: """ An initialisation sequence is a literal array of data bytes. The format is a repeated sequence of [CMD, ] @@ -111,7 +112,7 @@ def map_sequence(value): return cmd(*value) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if ( config.get(CONF_COLOR_PALETTE) == "IMAGE_ADAPTIVE" and CONF_COLOR_PALETTE_IMAGES not in config @@ -196,7 +197,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: global_config = full_config.get() # Ideally would calculate buffer size here, but that info is not available on the Python side needs_buffer = ( @@ -218,7 +219,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." ) @@ -278,7 +279,7 @@ async def to_code(config): cg.add(var.set_buffer_color_mode(ILI9XXXColorMode.BITS_8_INDEXED)) from PIL import Image - def load_image(filename): + def load_image(filename: str) -> Image.Image: path = CORE.relative_config_path(filename) try: return Image.open(path) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index bdc68b5257..57bf86c4c6 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -2,6 +2,9 @@ ESPHome configuration for the IT8951 e-paper controller. """ +from collections.abc import Callable +from typing import Any + from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import display, spi @@ -33,8 +36,10 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.cpp_generator import RawExpression +from esphome.core import ID +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] @@ -97,16 +102,16 @@ class IT8951Model: models: dict[str, "IT8951Model"] = {} - def __init__(self, name: str, **defaults): + def __init__(self, name: str, **defaults: Any) -> None: name = name.upper() self.name = name self.defaults = defaults IT8951Model.models[name] = self - def get_default(self, key, fallback=None): + def get_default(self, key: str, fallback: Any = None) -> Any: return self.defaults.get(key, fallback) - def get_dimensions(self, config) -> tuple[int, int]: + def get_dimensions(self, config: ConfigType) -> tuple[int, int]: # If dimensions are in config, use them; otherwise fall back to model defaults. if CONF_DIMENSIONS in config: dimensions = config[CONF_DIMENSIONS] @@ -181,14 +186,16 @@ DIMENSION_SCHEMA = cv.Schema( ) -def _model_pin_option(model, key, schema): +def _model_pin_option( + model: IT8951Model, key: str, schema: Callable[[Any], Any] +) -> tuple[cv.Optional | cv.Required, Callable[[Any], Any]]: default = model.get_default(key) if default is None: return cv.Required(key), schema return cv.Optional(key, default=default), schema -def _model_schema(config): +def _model_schema(config: ConfigType) -> cv.Schema: model = IT8951Model.models[config[CONF_MODEL]] has_default_dimensions = ( model.get_default(CONF_WIDTH) is not None @@ -293,7 +300,7 @@ def _model_schema(config): return schema.extend(pin_extra) -def _customise_schema(config): +def _customise_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of( @@ -336,7 +343,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( config @@ -356,7 +363,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = IT8951Model.models[config[CONF_MODEL]] width, height = model.get_dimensions(config) @@ -423,7 +430,12 @@ async def to_code(config): ), synchronous=True, ) -async def it8951_update_action_to_code(config, action_id, template_arg, args): +async def it8951_update_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: display_var = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, display_var) if mode := config.get(CONF_MODE): diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index 3c7d78a27b..cd846877ae 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -1,5 +1,6 @@ from collections.abc import Callable import difflib +from typing import Any import esphome.codegen as cg from esphome.components.const import KEY_METADATA @@ -13,6 +14,7 @@ from esphome.cpp_generator import ( add_global, ) from esphome.loader import get_component +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True @@ -32,13 +34,16 @@ class IndexType: """ def __init__( - self, validator: Callable, data_type: MockObj, conversion: Callable = None + self, + validator: Callable, + data_type: MockObj, + conversion: Callable | None = None, ) -> None: self.validator = validator self.data_type = data_type self.conversion = conversion - async def convert_value(self, value): + async def convert_value(self, value: Any) -> Any: if self.conversion: return self.conversion(value) return await cg.get_variable(value) @@ -60,7 +65,7 @@ class MappingMetaData: self.to_ = to_ -def to_schema(value): +def to_schema(value: Any) -> str: """ Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name. :param value: @@ -82,7 +87,7 @@ BASE_SCHEMA = cv.Schema( ) -def get_object_type(to_) -> MockObjClass | None: +def get_object_type(to_: str) -> MockObjClass | None: """ Get the object type from a string. Possible formats: xxx The name of a component which defines INSTANCE_TYPE @@ -121,7 +126,7 @@ def add_metadata( get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_) -def map_schema(config): +def map_schema(config: ConfigType) -> ConfigType: config = BASE_SCHEMA(config) if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict): raise cv.Invalid("an entries dictionary is required for a mapping") @@ -163,7 +168,7 @@ def map_schema(config): CONFIG_SCHEMA = map_schema -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: varid = config[CONF_ID] metadata = get_mapping_metadata(varid.id) entries = { diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 8c125a9606..b23982655a 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -53,6 +53,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import mipi_dsi_ns, models from .models import DsiDriverChip @@ -85,7 +86,7 @@ COLOR_DEPTHS = { } -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence @@ -148,7 +149,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -175,7 +176,7 @@ def _config_schema(config): return config -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -189,7 +190,7 @@ CONFIG_SCHEMA = _config_schema FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 897088a257..e23e19a000 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -1,5 +1,6 @@ import importlib import pkgutil +from typing import Any from esphome import pins import esphome.codegen as cg @@ -72,6 +73,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import models from .models import RgbDriverChip @@ -97,7 +99,7 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = DriverChip.get_models() -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -112,14 +114,14 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.All: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.Schema: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list @@ -213,7 +215,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -248,7 +250,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -265,7 +267,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index a20e9d1c01..cad5dc8e20 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -8,7 +8,7 @@ SDIR_CMD = 0xC7 class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring - def add_madctl(self, sequence: list, config: dict): + def add_madctl(self, sequence: list, config: dict) -> int: transform = self.get_transform(config) madctl = 0x00 if config[CONF_COLOR_ORDER] == MODE_BGR: diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 246db237b1..e8b54da5c7 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -53,8 +53,9 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.cpp_generator import TemplateArguments +from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.final_validate import full_config +from esphome.types import ConfigType from . import CONF_BUS_MODE, CONF_SPI_16, DOMAIN, models @@ -110,7 +111,7 @@ DISPLAY_PIXEL_MODES = { } -def denominator(config): +def denominator(config: ConfigType) -> int: """ Calculate the best denominator for a buffer size fraction. The denominator should be a number between 2 and 16 that divides the display height evenly, @@ -132,7 +133,7 @@ def denominator(config): return next(x for x in range(2, 17) if frac >= 1 / x) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All | cv.Schema: model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] transform = model.transform_schema() @@ -238,7 +239,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) -def customise_schema(config): +def customise_schema(config: ConfigType) -> ConfigType: """ Create a customised config schema for a specific model and validate the configuration. :param config: The configuration dictionary to validate @@ -305,7 +306,7 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() model = MODELS[config[CONF_MODEL]] @@ -341,7 +342,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -def get_instance(config): +def get_instance(config: ConfigType) -> tuple[MockObjClass, list]: """ Get the type of MipiSpi instance to create based on the configuration, and the template arguments. @@ -394,7 +395,7 @@ def get_instance(config): return MipiSpi, templateargs -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index cb86f93e29..ae785d17f9 100644 --- a/esphome/components/online_image/image.py +++ b/esphome/components/online_image/image.py @@ -6,7 +6,8 @@ from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestCom from esphome.components.image import CONF_TRANSPARENCY, add_metadata import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda +from esphome.core import ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["runtime_image"] @@ -89,7 +90,12 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( RELEASE_IMAGE_SCHEMA, synchronous=True, ) -async def online_image_action_to_code(config, action_id, template_arg, args): +async def online_image_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 7beb13ca31..c36d421a35 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -1,7 +1,9 @@ """ESPHome packet transport component.""" +from collections.abc import Callable, Iterator import hashlib import logging +from typing import Any import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor @@ -17,8 +19,9 @@ from esphome.const import ( CONF_PLATFORM, CONF_SENSORS, ) -from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] AUTO_LOAD = ["xxtea"] @@ -43,7 +46,7 @@ CONF_TRANSPORT_ID = "transport_id" _LOGGER = logging.getLogger(__name__) -def sensor_validation(cls: MockObjClass): +def sensor_validation(cls: MockObjClass) -> Callable[[Any], Any]: return cv.maybe_simple_value( cv.Schema( { @@ -55,7 +58,7 @@ def sensor_validation(cls: MockObjClass): ) -def provider_name_validate(value): +def provider_name_validate(value: Any) -> str: value = cv.valid_name(value) if "_" in value: _LOGGER.warning( @@ -83,7 +86,7 @@ PROVIDER_SCHEMA = cv.Schema( ).extend(ENCRYPTION_SCHEMA) -def validate_(config): +def validate_(config: ConfigType) -> ConfigType: if CONF_ENCRYPTION in config: if CONF_SENSORS not in config and CONF_BINARY_SENSORS not in config: raise cv.Invalid("No sensors or binary sensors to encrypt") @@ -117,11 +120,11 @@ TRANSPORT_SCHEMA = ( ) -def transport_schema(cls): +def transport_schema(cls: MockObjClass) -> cv.Schema: return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)}) -def get_sensors(transport_id): +def get_sensors(transport_id: ID) -> Iterator[ConfigType]: """Return the list of sensors for this platform.""" return ( sensor @@ -130,7 +133,7 @@ def get_sensors(transport_id): ) -def validate_packet_transport_sensor(config): +def validate_packet_transport_sensor(config: ConfigType) -> ConfigType: if CONF_NAME in config and CONF_INTERNAL not in config: raise cv.Invalid("Must provide internal: config when using name:") conf_sensors = CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) @@ -138,7 +141,7 @@ def validate_packet_transport_sensor(config): return config -def packet_transport_sensor_schema(base_schema): +def packet_transport_sensor_schema(base_schema: cv.Schema) -> cv.Schema: return cv.All( base_schema.extend( { @@ -152,11 +155,11 @@ def packet_transport_sensor_schema(base_schema): ) -def hash_encryption_key(config: dict): +def hash_encryption_key(config: dict) -> list[int]: return list(hashlib.sha256(config[CONF_KEY].encode()).digest()) -async def register_packet_transport(var, config): +async def register_packet_transport(var: MockObj, config: ConfigType) -> set[str]: var = await cg.register_component(var, config) cg.add(var.set_rolling_code_enable(config[CONF_ROLLING_CODE_ENABLE])) cg.add(var.set_ping_pong_enable(config[CONF_PING_PONG_ENABLE])) @@ -203,7 +206,7 @@ async def register_packet_transport(var, config): return providers -async def new_packet_transport(config): +async def new_packet_transport(config: ConfigType) -> tuple[MockObj, set[str]]: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_platform_name(config[CONF_PLATFORM])) providers = await register_packet_transport(var, config) diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 3291ff2c59..37c4688242 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from . import ( CONF_ENCRYPTION, @@ -44,7 +45,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: if config[CONF_TYPE] != CONF_STATUS: # Only run this validation if a status sensor is being configured return @@ -65,7 +66,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) comp = await cg.get_variable(config[CONF_TRANSPORT_ID]) if config[CONF_TYPE] == CONF_STATUS: diff --git a/esphome/components/packet_transport/sensor.py b/esphome/components/packet_transport/sensor.py index 15c0e33b30..018f1c3a9b 100644 --- a/esphome/components/packet_transport/sensor.py +++ b/esphome/components/packet_transport/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components.sensor import new_sensor, sensor_schema from esphome.const import CONF_ID +from esphome.types import ConfigType from . import ( CONF_PROVIDER, @@ -12,7 +13,7 @@ from . import ( CONFIG_SCHEMA = packet_transport_sensor_schema(sensor_schema()) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await new_sensor(config) comp = await cg.get_variable(config[CONF_TRANSPORT_ID]) remote_id = str(config.get(CONF_REMOTE_ID) or config.get(CONF_ID)) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index f49a68bc3f..5272df2b55 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -11,6 +11,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] AUTO_LOAD = ["gpio_expander"] @@ -40,7 +42,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) @@ -49,7 +51,7 @@ async def to_code(config): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -69,7 +71,9 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( ) -def pca9554_pin_final_validate(pin_config, parent_config): +def pca9554_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: count = parent_config[CONF_PIN_COUNT] if pin_config[CONF_NUMBER] >= count: raise cv.Invalid(f"Pin number must be in range 0-{count - 1}") @@ -78,7 +82,7 @@ def pca9554_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) -async def pca9554_pin_to_code(config): +async def pca9554_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_PCA9554]) diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 48cd72ecdf..dce1a95687 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -26,6 +27,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from . import CONF_DRAW_FROM_ORIGIN from .models import DriverChip @@ -49,14 +51,14 @@ DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema DELAY_FLAG = 0xFF -def validate_dimension(value): +def validate_dimension(value: Any) -> int: value = cv.positive_int(value) if value % 2 != 0: raise cv.Invalid("Width/height/offset must be divisible by 2") return value -def map_sequence(value): +def map_sequence(value: Any) -> list[int]: """ The format is a repeated sequence of [CMD, ] where is s a sequence of bytes. The length is inferred from the length of the sequence and should not be explicit. @@ -74,14 +76,14 @@ def map_sequence(value): return [value[0], len(params)] + list(params) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: chip = DriverChip.chips[config[CONF_MODEL]] if not chip.initsequence and CONF_INIT_SEQUENCE not in config: raise cv.Invalid(f"{chip.name} model requires init_sequence") return config -def power_of_two(value): +def power_of_two(value: Any) -> int: value = cv.int_range(1, 128)(value) if value & (value - 1) != 0: raise cv.Invalid("value must be a power of two") @@ -122,11 +124,11 @@ BASE_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( ) -def model_property(name, defaults, fallback): +def model_property(name: str, defaults: dict[str, Any], fallback: Any) -> cv.Optional: return cv.Optional(name, default=defaults.get(name, fallback)) -def model_schema(defaults): +def model_schema(defaults: dict[str, Any]) -> cv.Schema: transform = cv.Schema( { cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, @@ -162,7 +164,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." ) diff --git a/esphome/components/qspi_dbi/models.py b/esphome/components/qspi_dbi/models.py index 8ce592e0cf..7611279509 100644 --- a/esphome/components/qspi_dbi/models.py +++ b/esphome/components/qspi_dbi/models.py @@ -1,4 +1,6 @@ # Commands +from typing import Any + from esphome.components.const import CONF_DRAW_ROUNDING from esphome.const import CONF_INVERT_COLORS, CONF_SWAP_XY @@ -26,16 +28,16 @@ PAGESEL = 0xFE class DriverChip: - chips = {} + chips: dict[str, "DriverChip"] = {} - def __init__(self, name: str, defaults=None): + def __init__(self, name: str, defaults: dict[str, Any] | None = None) -> None: name = name.upper() self.name = name self.chips[name] = self self.initsequence = [] self.defaults = defaults or {} - def cmd(self, c, *args): + def cmd(self, c: int, *args: int) -> None: """ Add a command sequence to the init sequence :param c: The command (8 bit) @@ -43,7 +45,7 @@ class DriverChip: """ self.initsequence.extend([c, len(args)] + list(args)) - def delay(self, ms): + def delay(self, ms: int) -> None: self.initsequence.extend([ms, 0xFF]) diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 314852832c..1ca29a3259 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -38,6 +40,7 @@ from esphome.const import ( CONF_VSYNC_PIN, CONF_WIDTH, ) +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] LOGGER = logging.getLogger(__name__) @@ -53,7 +56,7 @@ COLOR_ORDERS = { DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -68,7 +71,7 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> Callable[[Any], Any]: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), @@ -128,7 +131,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." ) diff --git a/esphome/components/sdl/binary_sensor.py b/esphome/components/sdl/binary_sensor.py index e19a488800..0fdda25ed3 100644 --- a/esphome/components/sdl/binary_sensor.py +++ b/esphome/components/sdl/binary_sensor.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_KEY from esphome.core import Lambda from esphome.cpp_generator import ExpressionStatement, RawExpression +from esphome.types import ConfigType from .display import CONF_SDL_ID, Sdl @@ -275,7 +276,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) parent = await cg.get_variable(config[CONF_SDL_ID]) listener = Lambda( diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 57266f33e2..5ced2edf5a 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import subprocess +from typing import Any import esphome.codegen as cg from esphome.components import display @@ -14,6 +16,7 @@ from esphome.const import ( CONF_Y, PLATFORM_HOST, ) +from esphome.types import ConfigType sdl_ns = cg.esphome_ns.namespace("sdl") Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) @@ -35,7 +38,7 @@ WINDOW_OPTIONS = ( SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000 -def get_sdl_options(value): +def get_sdl_options(value: str) -> str: if value != "": return value try: @@ -46,7 +49,7 @@ def get_sdl_options(value): raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e -def get_window_options(): +def get_window_options() -> dict[cv.Optional, Callable[[Any], Any]]: return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS} @@ -100,7 +103,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: for option in config[CONF_SDL_OPTIONS].split(): cg.add_build_flag(option) cg.add_build_flag("-DSDL_BYTEORDER=4321") diff --git a/esphome/components/sdl/touchscreen/__init__.py b/esphome/components/sdl/touchscreen/__init__.py index 9f84f91c72..d7af8da403 100644 --- a/esphome/components/sdl/touchscreen/__init__.py +++ b/esphome/components/sdl/touchscreen/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import touchscreen import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from ..display import CONF_SDL_ID, Sdl, sdl_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SDL_ID]) await touchscreen.register_touchscreen(var, config) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index f71239d18c..56630f18f4 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["uart"] # is the code owner of the relevant code base @@ -43,7 +44,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( # The async def keyword is used to define a concurrent function. # Concurrent functions are special functions designed to work with Python's asyncio library to support asynchronous I/O operations. -async def to_code(config): +async def to_code(config: ConfigType) -> None: # This line of code creates a new Pvariable (a Python object representing a C++ variable) with the variable's ID taken from the configuration. var = cg.new_Pvariable(config[CONF_ID]) # This line of code registers the newly created Pvariable as a component so that ESPHome can manage it at runtime. diff --git a/esphome/components/seeed_mr24hpc1/binary_sensor.py b/esphome/components/seeed_mr24hpc1/binary_sensor.py index 26de1e4ac1..121eb2b4b3 100644 --- a/esphome/components/seeed_mr24hpc1/binary_sensor.py +++ b/esphome/components/seeed_mr24hpc1/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -13,7 +14,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button/__init__.py index 1e68d7e071..3386118bcf 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if restart_config := config.get(CONF_RESTART): b = await button.new_button(restart_config) diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number/__init__.py index 4de3654e39..d01618b0e6 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -63,7 +64,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if sensitivity_config := config.get(CONF_SENSITIVITY): n = await number.new_number( diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select/__init__.py index 14854f0795..9d46dee6f6 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -38,7 +39,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if scenemode_config := config.get(CONF_SCENE_MODE): s = await select.new_select( diff --git a/esphome/components/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index ca15fd5be6..36ee2c0087 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if custompresenceofdetection_config := config.get( CONF_CUSTOM_PRESENCE_OF_DETECTION diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch/__init__.py index 741e7de3ca..f9588d783e 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if underlying_open_function_config := config.get(CONF_UNDERLYING_OPEN_FUNCTION): s = await switch.new_switch(underlying_open_function_config) diff --git a/esphome/components/seeed_mr24hpc1/text_sensor.py b/esphome/components/seeed_mr24hpc1/text_sensor.py index fadd9c6dbc..8f284cb20a 100644 --- a/esphome/components/seeed_mr24hpc1/text_sensor.py +++ b/esphome/components/seeed_mr24hpc1/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -47,7 +48,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if heartbeat_config := config.get(CONF_HEART_BEAT): sens = await text_sensor.new_text_sensor(heartbeat_config) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 87bdbbd003..6bf8657af9 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/seeed_mr60bha2/binary_sensor.py b/esphome/components/seeed_mr60bha2/binary_sensor.py index 99940ebf6d..4130bac224 100644 --- a/esphome/components/seeed_mr60bha2/binary_sensor.py +++ b/esphome/components/seeed_mr60bha2/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_MR60BHA2_ID, MR60BHA2Component @@ -15,7 +16,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) if has_target_config := config.get(CONF_HAS_TARGET): diff --git a/esphome/components/seeed_mr60bha2/sensor.py b/esphome/components/seeed_mr60bha2/sensor.py index d7f667d862..a2f41a90a8 100644 --- a/esphome/components/seeed_mr60bha2/sensor.py +++ b/esphome/components/seeed_mr60bha2/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( UNIT_BEATS_PER_MINUTE, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from . import CONF_MR60BHA2_ID, MR60BHA2Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) if breath_rate_config := config.get(CONF_BREATH_RATE): sens = await sensor.new_sensor(breath_rate_config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index e79134deec..de6e8ad57b 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/seeed_mr60fda2/binary_sensor.py b/esphome/components/seeed_mr60fda2/binary_sensor.py index 2860ac0100..63bd02acd0 100644 --- a/esphome/components/seeed_mr60fda2/binary_sensor.py +++ b/esphome/components/seeed_mr60fda2/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_SAFETY +from esphome.types import ConfigType from . import CONF_MR60FDA2_ID, MR60FDA2Component @@ -21,7 +22,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if people_exist_config := config.get(CONF_PEOPLE_EXIST): diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button/__init__.py index 8236248b8c..82f0fc9aea 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ENTITY_CATEGORY_NONE, ) +from esphome.types import ConfigType from .. import CONF_MR60FDA2_ID, MR60FDA2Component, mr60fda2_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if get_radar_parameters_config := config.get(CONF_GET_RADAR_PARAMETERS): b = await button.new_button(get_radar_parameters_config) diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select/__init__.py index 2fea150cd2..6d8864455f 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG, ICON_ACCELERATION_Z +from esphome.types import ConfigType from .. import CONF_MR60FDA2_ID, MR60FDA2Component, mr60fda2_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if install_height_config := config.get(CONF_INSTALL_HEIGHT): s = await select.new_select( diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 7f6492812f..16d7ef8e86 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -41,6 +43,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from .init_sequences import ST7701S_INITS, cmd @@ -58,7 +61,7 @@ COLOR_ORDERS = { DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -73,14 +76,14 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.Schema: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def map_sequence(value): +def map_sequence(value: Any) -> list: """ An initialisation sequence can be selected from one of the pre-defined sequences in init_sequences.py, or can be a literal array of data bytes. @@ -170,7 +173,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/st7701s/init_sequences.py b/esphome/components/st7701s/init_sequences.py index 4786731c78..a67f3f63fb 100644 --- a/esphome/components/st7701s/init_sequences.py +++ b/esphome/components/st7701s/init_sequences.py @@ -1,7 +1,7 @@ # These are initialisation sequences for ST7701S displays. The contents are somewhat arcane. -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 5dfd188f0f..a782d875b9 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any, NoReturn + from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg @@ -13,7 +16,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID from esphome.core import ID -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -45,8 +48,8 @@ UDP_SCHEMA = cv.Schema( ) -def is_relocated(option): - def validator(value): +def is_relocated(option: str) -> Callable[[Any], NoReturn]: + def validator(value: Any) -> NoReturn: raise cv.Invalid( f"The '{option}' option should now be configured in the 'packet_transport' component" ) @@ -109,13 +112,13 @@ CONFIG_SCHEMA = cv.All( ) -async def register_udp_client(var, config): +async def register_udp_client(var: MockObj, config: ConfigType) -> MockObj: udp_var = await cg.get_variable(config[CONF_UDP_ID]) cg.add(var.set_parent(udp_var)) return udp_var -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_UDP") cg.add_global(udp_ns.using) var = cg.new_Pvariable(config[CONF_ID]) @@ -147,7 +150,7 @@ async def to_code(config): cg.add(var.set_should_listen()) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, str): @@ -171,7 +174,12 @@ def validate_raw_data(value): ), synchronous=True, ) -async def udp_write_to_code(config, action_id, template_arg, args): +async def udp_write_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) udp_var = await cg.get_variable(config[CONF_ID]) await cg.register_parented(var, udp_var) diff --git a/esphome/components/udp/packet_transport/__init__.py b/esphome/components/udp/packet_transport/__init__.py index e725276717..f2c15289a9 100644 --- a/esphome/components/udp/packet_transport/__init__.py +++ b/esphome/components/udp/packet_transport/__init__.py @@ -7,6 +7,7 @@ from esphome.components.packet_transport import ( ) from esphome.const import CONF_BINARY_SENSORS, CONF_ENCRYPTION, CONF_SENSORS from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import UDP_SCHEMA, register_udp_client, udp_ns @@ -15,7 +16,7 @@ UDPTransport = udp_ns.class_("UDPTransport", PacketTransport, PollingComponent) CONFIG_SCHEMA = transport_schema(UDPTransport).extend(UDP_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var, providers = await new_packet_transport(config) udp_var = await register_udp_client(var, config) if CONF_ENCRYPTION in config or providers: diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a921b6fbf0..edbf75f70f 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -18,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.cpp_types import Component +from esphome.types import ConfigType AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] CODEOWNERS = ["@clydebarrow"] @@ -48,14 +49,14 @@ DEFAULT_BAUD_RATE = 9600 class Type: def __init__( self, - name, - vid, - pid, - cls, - max_channels=1, - baud_rate_required=True, - max_baud=1_000_000, - ): + name: str, + vid: int, + pid: int, + cls: str | None, + max_channels: int = 1, + baud_rate_required: bool = True, + max_baud: int = 1_000_000, + ) -> None: self.name = name cls = cls or name self.vid = vid @@ -156,7 +157,7 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: list[ConfigType]) -> None: # The output chunk pool/queue are compile-time-sized templates shared by all # USBUartChannel instances, so use the largest buffer_size across every channel # of every device. Add one extra slot because LockFreeQueue is a ring From fbe4b39a165d7e2bd54a448c020f4640d2809dbe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:58:58 +1200 Subject: [PATCH 176/470] [core] Add type annotations to component Python (3/11) (#18340) --- .../components/copy/binary_sensor/__init__.py | 3 +- esphome/components/copy/button/__init__.py | 3 +- esphome/components/copy/cover/__init__.py | 3 +- esphome/components/copy/fan/__init__.py | 3 +- esphome/components/copy/lock/__init__.py | 3 +- esphome/components/copy/number/__init__.py | 3 +- esphome/components/copy/select/__init__.py | 3 +- esphome/components/copy/sensor/__init__.py | 3 +- esphome/components/copy/switch/__init__.py | 3 +- esphome/components/copy/text/__init__.py | 3 +- .../components/copy/text_sensor/__init__.py | 3 +- esphome/components/integration/sensor.py | 23 +++++++++--- esphome/components/key_collector/__init__.py | 19 +++++++--- .../key_collector/text_sensor/__init__.py | 4 +-- esphome/components/ledc/output.py | 20 ++++++++--- esphome/components/matrix_keypad/__init__.py | 5 +-- .../matrix_keypad/binary_sensor/__init__.py | 5 +-- esphome/components/pid/climate.py | 26 +++++++++++--- esphome/components/pid/sensor/__init__.py | 3 +- esphome/components/rp2/__init__.py | 15 ++++---- esphome/components/rp2/generate_boards.py | 2 +- esphome/components/rp2/gpio.py | 16 +++++---- esphome/components/rp2040_pwm/output.py | 12 +++++-- esphome/components/sn74hc165/__init__.py | 12 ++++--- esphome/components/sun/__init__.py | 24 ++++++++++--- esphome/components/sun/sensor/__init__.py | 3 +- .../components/sun/text_sensor/__init__.py | 5 +-- esphome/components/touchscreen/__init__.py | 22 ++++++++---- .../touchscreen/binary_sensor/__init__.py | 5 +-- esphome/components/update/__init__.py | 34 ++++++++++++------ esphome/components/vbus/__init__.py | 3 +- .../components/vbus/binary_sensor/__init__.py | 3 +- esphome/components/vbus/sensor/__init__.py | 3 +- .../components/voice_assistant/__init__.py | 35 +++++++++++++++---- .../components/xiaomi_rtcgq02lm/__init__.py | 3 +- .../xiaomi_rtcgq02lm/binary_sensor.py | 3 +- esphome/components/xiaomi_rtcgq02lm/sensor.py | 3 +- 37 files changed, 246 insertions(+), 95 deletions(-) diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index 840200409f..cc8492f21e 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 8028d6a217..768131bbe5 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -32,7 +33,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await button.register_button(var, config) await cg.register_component(var, config) diff --git a/esphome/components/copy/cover/__init__.py b/esphome/components/copy/cover/__init__.py index ff5bef5668..d23602fa74 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/fan/__init__.py b/esphome/components/copy/fan/__init__.py index a208e5f80a..ffa414c5f2 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -3,6 +3,7 @@ from esphome.components import fan import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 46bc08273e..8d9c4b6eca 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -3,6 +3,7 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index 3e2bbf2aae..9659a605f9 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index d7ddc52c44..97776b1edd 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -3,6 +3,7 @@ from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await select.register_select(var, config, options=[]) await cg.register_component(var, config) diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 57ca06aca7..5468798047 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -37,7 +38,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index ee27e38c5f..0e714540f9 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py index f1ca404b7b..59fdce6c96 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_MODE, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -26,7 +27,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 7b38ff1a64..146beae5ea 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 8d784df672..82e8ba8df8 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( @@ -39,14 +42,14 @@ CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" -def inherit_unit_of_measurement(uom, config): +def inherit_unit_of_measurement(uom: str, config: ConfigType) -> str: suffix = config[CONF_TIME_UNIT] if uom.endswith("/" + suffix): return uom[0 : -len("/" + suffix)] return uom + suffix -def inherit_accuracy_decimals(decimals, config): +def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int: return decimals + 2 @@ -90,7 +93,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -113,7 +116,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_integration_reset_to_code(config, action_id, template_arg, args): +async def sensor_integration_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -130,7 +138,12 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args ), synchronous=True, ) -async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): +async def sensor_integration_set_value_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 1f4519df2d..bf47b6df88 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,8 +15,9 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TRIGGER_ID, ) +from esphome.core import ID from esphome.cpp_generator import MockObj, literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@ssieb"] @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): @@ -144,7 +145,12 @@ async def to_code(config): ), synchronous=True, ) -async def enable_to_code(config, action_id, template_arg, args): +async def enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -160,7 +166,12 @@ async def enable_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def disable_to_code(config, action_id, template_arg, args): +async def disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/key_collector/text_sensor/__init__.py b/esphome/components/key_collector/text_sensor/__init__.py index 1676cf7bdf..e32d15df2e 100644 --- a/esphome/components/key_collector/text_sensor/__init__.py +++ b/esphome/components/key_collector/text_sensor/__init__.py @@ -4,7 +4,7 @@ from esphome.components.text_sensor import TextSensor import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.cpp_generator import literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType from .. import CONF_ON_RESULT, CONF_SOURCE_ID, TRIGGER_TYPES, KeyCollector @@ -15,7 +15,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SOURCE_ID]) var = cg.new_Pvariable(config[CONF_ID]) await text_sensor.register_text_sensor(var, config) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 95df1fba23..637e607b6d 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import output @@ -9,20 +11,23 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] -def calc_max_frequency(bit_depth): +def calc_max_frequency(bit_depth: int) -> float: return 80e6 / (2**bit_depth) -def calc_min_frequency(bit_depth): +def calc_min_frequency(bit_depth: int) -> float: max_div_num = ((2**20) - 1) / 256.0 return 80e6 / (max_div_num * (2**bit_depth)) -def validate_frequency(value): +def validate_frequency(value: Any) -> float: value = cv.frequency(value) min_freq = calc_min_frequency(20) max_freq = calc_max_frequency(1) @@ -56,7 +61,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -79,7 +84,12 @@ async def to_code(config): ), synchronous=True, ) -async def ledc_set_frequency_to_code(config, action_id, template_arg, args): +async def ledc_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 868b149211..47cf4793b1 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -4,6 +4,7 @@ from esphome.components import key_provider from esphome.components.const import CONF_ROWS import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -27,7 +28,7 @@ CONF_HAS_DIODES = "has_diodes" CONF_HAS_PULLDOWNS = "has_pulldowns" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -62,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) row_pins = [] diff --git a/esphome/components/matrix_keypad/binary_sensor/__init__.py b/esphome/components/matrix_keypad/binary_sensor/__init__.py index 8e63ed43ce..6c6e0aad73 100644 --- a/esphome/components/matrix_keypad/binary_sensor/__init__.py +++ b/esphome/components/matrix_keypad/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_ROW +from esphome.types import ConfigType from .. import CONF_KEYPAD_ID, MatrixKeypad, matrix_keypad_ns @@ -12,7 +13,7 @@ MatrixKeypadBinarySensor = matrix_keypad_ns.class_( ) -def check_button(obj): +def check_button(obj: ConfigType) -> ConfigType: if CONF_ROW in obj or CONF_COL in obj: if CONF_KEY in obj: raise cv.Invalid("You can't provide both a key and a position") @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_KEY in config: var = cg.new_Pvariable(config[CONF_ID], config[CONF_KEY][0]) else: diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 3e4ff754c9..4945547f2e 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) @@ -82,7 +85,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) @@ -141,7 +144,12 @@ async def to_code(config): ), synchronous=True, ) -async def pid_reset_integral_term(config, action_id, template_arg, args): +async def pid_reset_integral_term( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -163,7 +171,12 @@ async def pid_reset_integral_term(config, action_id, template_arg, args): ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) cg.add(var.set_noiseband(config[CONF_NOISEBAND])) @@ -185,7 +198,12 @@ async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_control_parameters(config, action_id, template_arg, args): +async def set_control_parameters( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index d26e88e38a..94d641de47 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import sensor from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.types import ConfigType from ..climate import PIDClimate, pid_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_CLIMATE_ID]) var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 60fcd4f8b0..ed975ec01a 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -34,6 +34,7 @@ from esphome.core import ( from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from . import boards @@ -145,7 +146,7 @@ def only_on_variant( return validator_ -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built RP2040 firmware. Used by device-builder (esphome/device-builder), via @@ -181,7 +182,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: return f"https://github.com/earlephilhower/arduino-pico/releases/download/{ver}/rp2040-{ver}.zip" -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: value = cv.string(value) if value.startswith("http"): return value @@ -205,7 +206,7 @@ RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), @@ -316,7 +317,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor @@ -588,7 +589,7 @@ def _generate_lwipopts_h() -> None: write_file_if_changed(lwip_dir / "lwipopts.h", content) -def add_pio_file(component: str, key: str, data: str): +def add_pio_file(component: str, key: str, data: str) -> None: try: cv.validate_id_name(key) except cv.Invalid as e: @@ -629,7 +630,7 @@ def generate_pio_files() -> bool: # Called by writer.py -def copy_files(): +def copy_files() -> None: dir = Path(__file__).parent post_build_file = dir / "post_build.py.script" copy_file_if_changed( @@ -670,7 +671,7 @@ def _addr2line(tool: str, elf: Path, addr: str) -> str: return f"{addr} (decode failed)" -def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: """Decode RP2040 crash handler output using addr2line.""" if _CRASH_RE.search(line): _LOGGER.error("RP2040 crash detected - decoding addresses") diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index cd3f50182c..4066ef6b34 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -256,7 +256,7 @@ def generate(arduino_pico_path: Path) -> str: return result.stdout.decode() -def main(): +def main() -> None: if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) diff --git a/esphome/components/rp2/gpio.py b/esphome/components/rp2/gpio.py index e4db6a831c..d325131178 100644 --- a/esphome/components/rp2/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv @@ -14,6 +16,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_RP2, rp2_ns @@ -21,7 +25,7 @@ from .const import KEY_BOARD, KEY_RP2, rp2_ns RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_RP2][KEY_BOARD] board_pins = boards.RP2_BOARD_PINS.get(board, {}) @@ -35,7 +39,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -54,12 +58,12 @@ def _translate_pin(value): return _lookup_pin(value) -def _board_max_virtual_pin(board): +def _board_max_virtual_pin(board: str) -> int | None: """Get the max CYW43 virtual pin for this board, or None if no virtual pins.""" return boards.BOARDS.get(board, {}).get("max_virtual_pin") -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) @@ -71,7 +75,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None @@ -100,7 +104,7 @@ RP2_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) -async def rp2_pin_to_code(config): +async def rp2_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a2fda58c9e..a0344e8054 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["rp2"] @@ -22,7 +25,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) @@ -44,7 +47,12 @@ async def to_code(config): ), synchronous=True, ) -async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): +async def rp2040_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index f2ba5fedd1..4f21312fec 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = [] @@ -38,7 +40,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) @@ -54,7 +56,7 @@ async def to_code(config): cg.add(var.set_sr_count(config[CONF_SR_COUNT])) -def _validate_input_mode(value): +def _validate_input_mode(value: bool) -> bool: if value is not True: raise cv.Invalid("Only input mode is supported") return value @@ -77,7 +79,9 @@ SN74HC165_PIN_SCHEMA = cv.All( ) -def sn74hc165_pin_final_validate(pin_config, parent_config): +def sn74hc165_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: max_pins = parent_config[CONF_SR_COUNT] * 8 if pin_config[CONF_NUMBER] >= max_pins: raise cv.Invalid(f"Pin number must be less than {max_pins}") @@ -86,7 +90,7 @@ def sn74hc165_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_SN74HC165, SN74HC165_PIN_SCHEMA, sn74hc165_pin_final_validate ) -async def sn74hc165_pin_to_code(config): +async def sn74hc165_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SN74HC165]) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index c065a82958..33a5c677bd 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -1,5 +1,6 @@ import contextlib import re +from typing import Any from esphome import automation import esphome.codegen as cg @@ -12,6 +13,9 @@ from esphome.const import ( CONF_TIME_ID, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] sun_ns = cg.esphome_ns.namespace("sun") @@ -40,7 +44,7 @@ ELEVATION_MAP = { } -def elevation(value): +def elevation(value: Any) -> float: if isinstance(value, str): with contextlib.suppress(cv.Invalid): value = ELEVATION_MAP[ @@ -60,7 +64,7 @@ LAT_LON_REGEX = re.compile( ) -def parse_latlon(value): +def parse_latlon(value: Any) -> float: if isinstance(value, str) and value.endswith("°"): # strip trailing degree character value = value[:-1] @@ -114,7 +118,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) time_ = await cg.get_variable(config[CONF_TIME_ID]) cg.add(var.set_time(time_)) @@ -150,7 +154,12 @@ async def to_code(config): } ), ) -async def sun_above_horizon_to_code(config, condition_id, template_arg, args): +async def sun_above_horizon_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) @@ -171,7 +180,12 @@ async def sun_above_horizon_to_code(config, condition_id, template_arg, args): } ), ) -async def sun_below_horizon_to_code(config, condition_id, template_arg, args): +async def sun_below_horizon_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index a1ced8ff5b..d2e9fa750d 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DEGREES, ) +from esphome.types import ConfigType from .. import CONF_SUN_ID, Sun, sun_ns @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/sun/text_sensor/__init__.py b/esphome/components/sun/text_sensor/__init__.py index fc733d3435..523471bd41 100644 --- a/esphome/components/sun/text_sensor/__init__.py +++ b/esphome/components/sun/text_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_WEATHER_SUNSET_DOWN, ICON_WEATHER_SUNSET_UP, ) +from esphome.types import ConfigType from .. import CONF_ELEVATION, CONF_SUN_ID, DEFAULT_ELEVATION, Sun, elevation, sun_ns @@ -22,7 +23,7 @@ SUN_TYPES = { } -def validate_optional_icon(config): +def validate_optional_icon(config: ConfigType) -> ConfigType: if CONF_ICON not in config: config = config.copy() config[CONF_ICON] = { @@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index cf0c5fca19..c8b918007b 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -1,3 +1,7 @@ +from typing import Any + +import voluptuous as vol + from esphome import automation import esphome.codegen as cg from esphome.components import display @@ -14,6 +18,8 @@ from esphome.const import ( CONF_TRANSFORM, ) from esphome.core import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@nielsnl68"] DEPENDENCIES = ["display"] @@ -40,7 +46,7 @@ CONF_Y_MIN = "y_min" CONF_Y_MAX = "y_max" -def validate_calibration(calibration_config): +def validate_calibration(calibration_config: ConfigType) -> ConfigType: x_min = calibration_config[CONF_X_MIN] x_max = calibration_config[CONF_X_MAX] y_min = calibration_config[CONF_Y_MIN] @@ -60,7 +66,9 @@ def validate_calibration(calibration_config): return calibration_config -def option_with_default(option: str, defaults: dict, required: bool = False): +def option_with_default( + option: str, defaults: dict, required: bool = False +) -> vol.Marker: if option in defaults or not required: return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) return cv.Required(option) @@ -119,9 +127,9 @@ def _transform_schema(defaults: dict) -> dict: def touchscreen_schema( - default_touch_timeout=cv.UNDEFINED, - calibration_required=False, - defaults: dict = None, + default_touch_timeout: Any = cv.UNDEFINED, + calibration_required: bool = False, + defaults: dict | None = None, ) -> cv.Schema: defaults = defaults or {} return cv.Schema( @@ -143,7 +151,7 @@ def touchscreen_schema( TOUCHSCREEN_SCHEMA = touchscreen_schema(cv.UNDEFINED) -async def register_touchscreen(var, config): +async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) disp = await cg.get_variable(config[CONF_DISPLAY]) @@ -192,6 +200,6 @@ async def register_touchscreen(var, config): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(touchscreen_ns.using) cg.add_define("USE_TOUCHSCREEN") diff --git a/esphome/components/touchscreen/binary_sensor/__init__.py b/esphome/components/touchscreen/binary_sensor/__init__.py index 5ce0defb31..6a66d00ea6 100644 --- a/esphome/components/touchscreen/binary_sensor/__init__.py +++ b/esphome/components/touchscreen/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor, display import esphome.config_validation as cv from esphome.const import CONF_PAGE_ID, CONF_PAGES +from esphome.types import ConfigType from .. import CONF_TOUCHSCREEN_ID, TouchListener, Touchscreen, touchscreen_ns @@ -22,7 +23,7 @@ CONF_Y_MAX = "y_max" CONF_USE_RAW = "use_raw" -def _validate_coords(config): +def _validate_coords(config: ConfigType) -> ConfigType: if ( config[CONF_X_MAX] < config[CONF_X_MIN] or config[CONF_Y_MAX] < config[CONF_Y_MIN] @@ -66,7 +67,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_TOUCHSCREEN_ID]) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 18d333a5ef..5ebe58881d 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,14 +14,15 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] IS_PLATFORM_COMPONENT = True @@ -95,7 +96,7 @@ def update_schema( @setup_entity("update") -async def setup_update_core_(var, config): +async def setup_update_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if on_update_available := config.get(CONF_ON_UPDATE_AVAILABLE): @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_update(var, config): +async def register_update(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("update", config) @@ -121,14 +122,14 @@ async def register_update(var, config): await setup_update_core_(var, config) -async def new_update(config): +async def new_update(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_update(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(update_ns.using) @@ -145,7 +146,12 @@ async def to_code(config): ), synchronous=True, ) -async def update_perform_action_to_code(config, action_id, template_arg, args): +async def update_perform_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -164,7 +170,12 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def update_check_action_to_code(config, action_id, template_arg, args): +async def update_check_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -180,8 +191,11 @@ async def update_check_action_to_code(config, action_id, template_arg, args): ), ) async def update_is_available_condition_to_code( - config, condition_id, template_arg, args -): + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 2663496456..94857050f2 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -29,7 +30,7 @@ CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 85f1172166..5c09a025f8 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC, ) +from esphome.types import ConfigType from .. import ( CONF_DELTASOL_BS2, @@ -256,7 +257,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index 9c3665eb1c..e8a6ea7bfa 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import ( CONF_DELTASOL_BS2, @@ -650,7 +651,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index f41adfd8de..d30eaf4768 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -14,6 +14,9 @@ from esphome.const import ( CONF_ON_START, CONF_SPEAKER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] @@ -78,7 +81,7 @@ ConnectedCondition = voice_assistant_ns.class_( Timer = voice_assistant_ns.struct("Timer") -def tts_stream_validate(config): +def tts_stream_validate(config: ConfigType) -> ConfigType: if CONF_SPEAKER not in config and ( CONF_ON_TTS_STREAM_START in config or CONF_ON_TTS_STREAM_END in config ): @@ -199,7 +202,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -420,7 +423,12 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis ), synchronous=True, ) -async def voice_assistant_listen_to_code(config, action_id, template_arg, args): +async def voice_assistant_listen_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) if CONF_SILENCE_DETECTION in config: @@ -434,7 +442,12 @@ async def voice_assistant_listen_to_code(config, action_id, template_arg, args): @register_action( "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True ) -async def voice_assistant_stop_to_code(config, action_id, template_arg, args): +async def voice_assistant_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -443,7 +456,12 @@ async def voice_assistant_stop_to_code(config, action_id, template_arg, args): @register_condition( "voice_assistant.is_running", IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA ) -async def voice_assistant_is_running_to_code(config, condition_id, template_arg, args): +async def voice_assistant_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -452,7 +470,12 @@ async def voice_assistant_is_running_to_code(config, condition_id, template_arg, @register_condition( "voice_assistant.connected", ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA ) -async def voice_assistant_connected_to_code(config, condition_id, template_arg, args): +async def voice_assistant_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 3e235d985f..7b289a8ee3 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS +from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 8d0508b59b..57420125cb 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from . import XiaomiRTCGQ02LM @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if CONF_MOTION in config: diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e49f1c960b..e0e4b4640b 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import XiaomiRTCGQ02LM @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if CONF_BATTERY_LEVEL in config: From b6a9761dae4b015a30d9dd2492c408ff3ea424b1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:59:51 +1200 Subject: [PATCH 177/470] [core] Add type annotations to component Python (4/11) (#18341) --- esphome/components/aic3204/audio_dac.py | 12 +++++- esphome/components/audio_adc/__init__.py | 13 ++++-- esphome/components/audio_dac/__init__.py | 20 ++++++++-- esphome/components/bme68x_bsec2/__init__.py | 7 ++-- esphome/components/bme68x_bsec2/sensor.py | 6 ++- .../components/bme68x_bsec2/text_sensor.py | 6 ++- .../components/dfrobot_sen0395/__init__.py | 23 +++++++++-- .../dfrobot_sen0395/binary_sensor.py | 3 +- .../dfrobot_sen0395/switch/__init__.py | 3 +- esphome/components/dlms_meter/__init__.py | 14 ++++--- .../dlms_meter/binary_sensor/__init__.py | 3 +- .../components/dlms_meter/sensor/__init__.py | 5 ++- .../dlms_meter/text_sensor/__init__.py | 5 ++- esphome/components/ina2xx_base/__init__.py | 11 +++-- esphome/components/logger/__init__.py | 30 +++++++++----- esphome/components/logger/select/__init__.py | 3 +- esphome/components/ltr501/sensor.py | 13 +++--- esphome/components/ltr_als_ps/sensor.py | 11 +++-- esphome/components/msa3xx/__init__.py | 3 +- esphome/components/msa3xx/binary_sensor.py | 3 +- esphome/components/msa3xx/sensor.py | 3 +- esphome/components/msa3xx/text_sensor.py | 6 ++- esphome/components/ota/__init__.py | 10 +++-- esphome/components/safe_mode/__init__.py | 16 +++++--- .../components/safe_mode/button/__init__.py | 3 +- .../components/safe_mode/switch/__init__.py | 3 +- esphome/components/spi/__init__.py | 40 ++++++++++--------- esphome/components/st7789v/display.py | 10 +++-- esphome/components/substitutions/jinja.py | 12 +++--- esphome/components/thermostat/climate.py | 18 ++++++--- .../waveshare_io_ch32v003/__init__.py | 8 ++-- .../waveshare_io_ch32v003/output/__init__.py | 5 ++- .../waveshare_io_ch32v003/sensor/__init__.py | 3 +- esphome/components/web_server/__init__.py | 12 +++--- esphome/components/web_server/ota/__init__.py | 2 +- 35 files changed, 229 insertions(+), 116 deletions(-) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b478b573a3..50e2f81f1b 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,6 +4,9 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( SET_AUTO_MUTE_ACTION_SCHEMA, synchronous=True, ) -async def aic3204_set_volume_to_code(config, action_id, template_arg, args): +async def aic3204_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c3a4988b5..c2bdfb6cb0 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -2,7 +2,9 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -28,7 +30,12 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( SET_MIC_GAIN_ACTION_SCHEMA, synchronous=True, ) -async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): +async def audio_adc_set_mic_gain_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -39,6 +46,6 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_ADC") cg.add_global(audio_adc_ns.using) diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 46c277ce51..1351793afd 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -3,7 +3,9 @@ from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -37,7 +39,12 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True ) -async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): +async def audio_dac_mute_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -48,7 +55,12 @@ async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): SET_VOLUME_ACTION_SCHEMA, synchronous=True, ) -async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): +async def audio_dac_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -59,6 +71,6 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_DAC") cg.add_global(audio_dac_ns.using) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index c12eb39d2d..8208672b6a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.cpp_generator import MockObj from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -94,7 +95,7 @@ def _compute_url(config: dict) -> str: return f"https://raw.githubusercontent.com/boschsensortec/Bosch-BSEC2-Library/{BSEC2_LIBRARY_VERSION}/src/config/{model}/{model}_{algo}_{volts}_{sample_rate}_{operating_age}/{filename}.txt" -def download_bme68x_blob(config): +def download_bme68x_blob(config: ConfigType) -> ConfigType: url = _compute_url(config) path = _compute_local_file_path(url) external_files.download_content(url, path) @@ -138,7 +139,7 @@ def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) -def validate_bme68x(config): +def validate_bme68x(config: ConfigType) -> ConfigType: if CONF_ALGORITHM_OUTPUT not in config: return config @@ -178,7 +179,7 @@ CONFIG_SCHEMA_BASE = ( ) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index 52587dba99..863cd9d601 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -29,6 +29,8 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component @@ -119,7 +121,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await sensor.new_sensor(conf) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -127,7 +129,7 @@ async def setup_conf(config, key, hub): cg.add(getattr(hub, f"set_{key}_sample_rate")(sample_rate)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme68x_bsec2/text_sensor.py b/esphome/components/bme68x_bsec2/text_sensor.py index fce00afe34..5c6f9f696c 100644 --- a/esphome/components/bme68x_bsec2/text_sensor.py +++ b/esphome/components/bme68x_bsec2/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, BME68xBSEC2Component @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await text_sensor.new_text_sensor(conf) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 943c510279..51562f923c 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_SENSITIVITY +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@niklasweber"] DEPENDENCIES = ["uart"] @@ -38,7 +43,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -54,14 +59,19 @@ async def to_code(config): ), synchronous=True, ) -async def dfrobot_sen0395_reset_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -def range_segment_list(input): +def range_segment_list(input: Any) -> list: """Validate input is a list of ranges which can be used to configure the dfrobot mmwave radar A list of segments should be provided. A minimum of one segment is required and a maximum of @@ -154,7 +164,12 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema( MMWAVE_SETTINGS_SCHEMA, synchronous=True, ) -async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/dfrobot_sen0395/binary_sensor.py b/esphome/components/dfrobot_sen0395/binary_sensor.py index 193ef925a4..e299c35a42 100644 --- a/esphome/components/dfrobot_sen0395/binary_sensor.py +++ b/esphome/components/dfrobot_sen0395/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_MOTION +from esphome.types import ConfigType from . import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -16,7 +17,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) binary_sens = await binary_sensor.new_binary_sensor(config) diff --git a/esphome/components/dfrobot_sen0395/switch/__init__.py b/esphome/components/dfrobot_sen0395/switch/__init__.py index 8e492080de..22aaa1640c 100644 --- a/esphome/components/dfrobot_sen0395/switch/__init__.py +++ b/esphome/components/dfrobot_sen0395/switch/__init__.py @@ -3,6 +3,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType from .. import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index b747f73a14..00a1694cc3 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,5 +1,6 @@ import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -33,13 +35,13 @@ DlmsMeterComponent = dlms_meter_component_ns.class_( ) -def obis_code(value): +def obis_code(value: Any) -> str: # 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): +def parse_obis_code_bytes(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -57,19 +59,19 @@ def parse_obis_code_bytes(value): return bytes_list -def custom_pattern_dict(value): +def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(value): +def validate_custom_pattern(value: ConfigType) -> ConfigType: 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): +def validate_provider_deprecation(config: ConfigType) -> ConfigType: if CONF_PROVIDER in config: provider = str(config[CONF_PROVIDER]).lower() if provider == "netznoe": @@ -154,7 +156,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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)] diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index f9bc1d9df7..a15e58b957 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -14,7 +15,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index ec4639351d..8ded150cd0 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -47,7 +48,7 @@ DYNAMIC_SCHEMA = sensor.sensor_schema().extend( ) -def deprecation_warning(config): +def deprecation_warning(config: ConfigType) -> ConfigType: _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'." @@ -145,7 +146,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 0bfb43a285..c2ff0779ee 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -3,6 +3,7 @@ import logging import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -23,7 +24,7 @@ DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( ) -def deprecation_warning(config): +def deprecation_warning(config: ConfigType) -> ConfigType: _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'." @@ -46,7 +47,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 15e2faba07..7bb589f0b1 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_AMPERE_HOUR @@ -26,6 +28,9 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import EnumValue +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -76,7 +81,7 @@ SENSOR_MODEL_OPTIONS = { } -def validate_model_config(config): +def validate_model_config(config: ConfigType) -> ConfigType: model = config[CONF_MODEL] for key in config: @@ -92,7 +97,7 @@ def validate_model_config(config): return config -def validate_adc_time(value): +def validate_adc_time(value: Any) -> EnumValue: value = cv.positive_time_period_microseconds(value).total_microseconds return cv.enum(ADC_TIMES, int=True)(value) @@ -198,7 +203,7 @@ INA2XX_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def setup_ina2xx(var, config): +async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index f307f5d5d1..07b8b03084 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,4 +1,5 @@ import re +from typing import Any from esphome import automation from esphome.automation import LambdaAction, StatelessLambdaAction @@ -58,7 +59,8 @@ from esphome.const import ( PLATFORM_RTL87XX, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -164,7 +166,7 @@ HARDWARE_UART_TO_SERIAL = { is_log_level = cv.one_of(*LOG_LEVELS, upper=True) -def uart_selection(value): +def uart_selection(value: Any) -> str: if CORE.is_esp32: variant = get_esp32_variant() if variant in UART_SELECTION_ESP32: @@ -187,7 +189,7 @@ def uart_selection(value): raise NotImplementedError -def validate_local_no_higher_than_global(config): +def validate_local_no_higher_than_global(config: ConfigType) -> ConfigType: global_level = config[CONF_LEVEL] global_level_index = LOG_LEVEL_SEVERITY.index(global_level) errs = [] @@ -204,7 +206,7 @@ def validate_local_no_higher_than_global(config): return config -def validate_initial_no_higher_than_global(config): +def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: if initial_level := config.get(CONF_INITIAL_LEVEL): global_level = config[CONF_LEVEL] if LOG_LEVEL_SEVERITY.index(initial_level) > LOG_LEVEL_SEVERITY.index( @@ -217,7 +219,7 @@ def validate_initial_no_higher_than_global(config): return config -def validate_wait_for_cdc(config): +def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") return config @@ -518,7 +520,7 @@ async def _late_logger_init(config: ConfigType) -> None: CORE.add_job(final_step) -def validate_printf(value): +def validate_printf(value: ConfigType) -> ConfigType: # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" ( # start of capture group 1 @@ -559,7 +561,12 @@ LOGGER_LOG_ACTION_SCHEMA = cv.All( @automation.register_action( CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, synchronous=True ) -async def logger_log_action_to_code(config, action_id, template_arg, args): +async def logger_log_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: esp_log = LOG_LEVEL_TO_ESP_LOG[config[CONF_LEVEL]] args_ = [cg.RawExpression(str(x)) for x in config[CONF_ARGS]] @@ -584,7 +591,12 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def logger_set_level_to_code(config, action_id, template_arg, args): +async def logger_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: level = LOG_LEVELS[config[CONF_LEVEL]] logger = await cg.get_variable(config[CONF_LOGGER_ID]) if tag := config.get(CONF_TAG): @@ -656,7 +668,7 @@ def request_log_listener() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional logger features.""" domain_data = CORE.data.get(DOMAIN, {}) if domain_data.get(KEY_LEVEL_LISTENERS, False): diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 6ce663978e..00f67422f3 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented +from esphome.types import ConfigType from .. import ( CONF_LOGGER_ID, @@ -26,7 +27,7 @@ CONFIG_SCHEMA = select.select_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c1fa9009b3..c2091a6336 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -87,17 +90,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -107,7 +110,7 @@ def validate_time_and_repeat_rate(config): return config -def validate_als_gain_and_integration_time(config): +def validate_als_gain_and_integration_time(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] if config[CONF_GAIN] == "1X" and integraton_time > 100: raise cv.Invalid( @@ -221,7 +224,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 893415f028..af09282e2d 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -23,6 +25,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -93,17 +96,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -211,7 +214,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 04514b584f..0beece6710 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TRANSFORM, CONF_TYPE, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -123,7 +124,7 @@ MSA_SENSOR_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index 732a0ed291..ef27c98e66 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -31,7 +32,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for sensor in EVENT_SENSORS: diff --git a/esphome/components/msa3xx/sensor.py b/esphome/components/msa3xx/sensor.py index 63f050fa05..22bcb94025 100644 --- a/esphome/components/msa3xx/sensor.py +++ b/esphome/components/msa3xx/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER_PER_SECOND_SQUARED, ) +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -34,7 +35,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for accel_key in ACCELERATION_SENSORS: if accel_key in config: diff --git a/esphome/components/msa3xx/text_sensor.py b/esphome/components/msa3xx/text_sensor.py index c53a4aa139..6693ec8542 100644 --- a/esphome/components/msa3xx/text_sensor.py +++ b/esphome/components/msa3xx/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_NAME +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -25,13 +27,13 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for key in ORIENTATION_SENSORS: diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 1e2ee947c1..5240db9e8f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType OTA_STATE_LISTENER_KEY = "ota_state_listener" @@ -49,7 +51,7 @@ OTAStateChangeTrigger = ota_ns.class_( ) -def _ota_final_validate(config): +def _ota_final_validate(config: ConfigType) -> None: if len(config) < 1: raise cv.Invalid( f"At least one platform must be specified for '{CONF_OTA}'; add '{CONF_PLATFORM}: {CONF_ESPHOME}' for original OTA functionality" @@ -95,7 +97,7 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA") CORE.add_job(final_step) @@ -103,7 +105,7 @@ async def to_code(config): cg.add_library("Updater", None) -async def ota_to_code(var, config): +async def ota_to_code(var: MockObj, config: ConfigType) -> None: await cg.past_safe_mode() use_state_callback = False for conf in config.get(CONF_ON_STATE_CHANGE, []): @@ -145,7 +147,7 @@ def request_ota_state_listeners() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional OTA features.""" if CORE.data.get(OTA_STATE_LISTENER_KEY, False): cg.add_define("USE_OTA_STATE_LISTENER") diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 70096a56bc..9bc8a263c8 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,8 +10,9 @@ from esphome.const import ( CONF_STORAGE, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import RawExpression +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -24,7 +25,7 @@ SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) -def _remove_id_if_disabled(value): +def _remove_id_if_disabled(value: ConfigType) -> ConfigType: value = value.copy() if value[CONF_DISABLED]: value.pop(CONF_ID) @@ -62,7 +63,12 @@ CONFIG_SCHEMA = cv.All( ), synchronous=True, ) -async def safe_mode_mark_successful_to_code(config, action_id, template_arg, args): +async def safe_mode_mark_successful_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg) cg.add(var.set_parent(parent)) @@ -75,7 +81,7 @@ _CALLBACK_AUTOMATIONS = ( @coroutine_with_priority(CoroPriority.APPLICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if not config[CONF_DISABLED]: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index 0731ca50f5..89e2475799 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import SafeModeComponent, safe_mode_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch/__init__.py index d656eee84a..529b023d68 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_SAFE_MODE, ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import SafeModeComponent, safe_mode_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 608adc7514..d7b85ee20d 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -83,7 +83,7 @@ def _render_hz(value: float) -> str: return formatted + unit -def _frequency_validator(value): +def _frequency_validator(value: Any) -> float: platform = get_target_platform() frequency = PLATFORM_SPI_CLOCKS[platform] value = cv.frequency(value) @@ -153,17 +153,17 @@ RP_SPI_PINSETS = [ ] -def get_target_platform(): +def get_target_platform() -> str: return CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] -def get_target_variant(): +def get_target_variant() -> str: return CORE.data[KEY_ESP32].get(KEY_VARIANT, "") # Get a list of available hardware interfaces based on target and variant. # The returned value is a list of lists of names -def get_hw_interface_list(): +def get_hw_interface_list() -> list[list[str]]: target_platform = get_target_platform() if target_platform == PLATFORM_ESP8266: return [["spi", "hspi"]] @@ -196,7 +196,7 @@ def one_of_interface_validator(additional_values: list[str] | None = None) -> An if additional_values is None: additional_values = [] - def validator(value: str) -> str: + def validator(value: Any) -> str: return cv.one_of( *sum(get_hw_interface_list(), additional_values), lower=True, @@ -206,7 +206,7 @@ def one_of_interface_validator(additional_values: list[str] | None = None) -> An # Given an SPI name, return the index of it in the available list -def get_spi_index(name): +def get_spi_index(name: str) -> int: for i, ilist in enumerate(get_hw_interface_list()): if name in ilist: return i @@ -218,7 +218,7 @@ def get_spi_index(name): # \param spi the config data for the spi instance # \param index the selected hw interface number, -1 if not yet known # TODO verify that the pins are internal -def validate_hw_pins(spi, index=-1): +def validate_hw_pins(spi: ConfigType, index: int = -1) -> bool: clk_pin = spi[CONF_CLK_PIN] if clk_pin[CONF_INVERTED]: return False @@ -265,7 +265,7 @@ def validate_hw_pins(spi, index=-1): return False -def get_hw_spi(config, available): +def get_hw_spi(config: ConfigType, available: list[int]) -> int | None: """Get an available hardware spi interface suitable for this config""" matching = list(filter(lambda idx: validate_hw_pins(config, idx), available)) if len(matching) != 0: @@ -273,7 +273,7 @@ def get_hw_spi(config, available): return None -def validate_spi_config(config): +def validate_spi_config(config: list[ConfigType]) -> list[ConfigType]: available = list(range(len(get_hw_interface_list()))) for spi in config: interface = spi[CONF_INTERFACE] @@ -317,7 +317,7 @@ def validate_spi_config(config): # Given an SPI index, convert to a string that represents the C++ object for it. -def get_spi_interface(index): +def get_spi_interface(index: int) -> str: platform = get_target_platform() if platform == PLATFORM_ESP32: # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks @@ -353,7 +353,7 @@ SPI_SINGLE_SCHEMA = cv.All( ) -def spi_mode_schema(mode): +def spi_mode_schema(mode: str) -> cv.Schema: if mode == TYPE_SINGLE: return SPI_SINGLE_SCHEMA pin_count = 4 if mode == TYPE_QUAD else 8 @@ -400,7 +400,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.BUS) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: cg.add_define("USE_SPI") cg.add_global(spi_ns.using) if CORE.using_arduino and not CORE.is_esp32: @@ -427,11 +427,11 @@ async def to_code(configs): def spi_device_schema( - cs_pin_required=True, - default_data_rate=cv.UNDEFINED, - default_mode=cv.UNDEFINED, - mode=TYPE_SINGLE, -): + cs_pin_required: bool = True, + default_data_rate: Any = cv.UNDEFINED, + default_mode: Any = cv.UNDEFINED, + mode: str = TYPE_SINGLE, +) -> cv.Schema: """Create a schema for an SPI device. :param cs_pin_required: If true, make the CS_PIN required in the config. :param default_data_rate: Optional data_rate to use as default @@ -456,7 +456,7 @@ def spi_device_schema( async def register_spi_device( - var: cg.Pvariable, config: ConfigType, write_only: bool = False + var: cg.MockObj, config: ConfigType, write_only: bool = False ) -> None: parent = await cg.get_variable(config[CONF_SPI_ID]) cg.add(var.set_spi_parent(parent)) @@ -473,7 +473,9 @@ async def register_spi_device( cg.add(var.set_release_device(release_device)) -def final_validate_device_schema(name: str, *, require_mosi: bool, require_miso: bool): +def final_validate_device_schema( + name: str, *, require_mosi: bool, require_miso: bool +) -> cv.Schema: hub_schema = {} if require_miso: hub_schema[ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 3b4d6d99ea..fa72c7c328 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -19,6 +20,7 @@ from esphome.const import ( CONF_ROTATION, CONF_WIDTH, ) +from esphome.types import ConfigType from . import st7789v_ns @@ -38,7 +40,9 @@ MODEL_PRESETS = "model_presets" REQUIRE_PS = "require_ps" -def model_spec(require_ps=False, presets=None): +def model_spec( + require_ps: bool = False, presets: dict[str, Any] | None = None +) -> dict[str, Any]: if presets is None: presets = {} return {MODEL_PRESETS: presets, REQUIRE_PS: require_ps} @@ -119,7 +123,7 @@ MODELS = { } -def validate_st7789v(config): +def validate_st7789v(config: ConfigType) -> ConfigType: model_data = MODELS[config[CONF_MODEL]] presets = model_data[MODEL_PRESETS] for key, value in presets.items(): @@ -178,7 +182,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." ) diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index 36a7425a69..230ad09df9 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -43,23 +43,23 @@ SAFE_GLOBALS = { class JinjaError(Exception): - def __init__(self, context_trace: dict, expr: str): + def __init__(self, context_trace: dict, expr: str) -> None: self.context_trace = context_trace self.eval_stack = [expr] - def parent(self): + def parent(self) -> BaseException | None: return self.__context__ - def error_name(self): + def error_name(self) -> str: return type(self.parent()).__name__ - def context_trace_str(self): + def context_trace_str(self) -> str: return "\n".join( f" {k} = {repr(v)} ({type(v).__name__})" for k, v in self.context_trace.items() ) - def stack_trace_str(self): + def stack_trace_str(self) -> str: return "\n".join( f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}" for i, expr in enumerate(self.eval_stack) @@ -67,7 +67,7 @@ class JinjaError(Exception): class TrackerContext(jinja.runtime.Context): - def resolve_or_missing(self, key): + def resolve_or_missing(self, key: str) -> Any: val = super().resolve_or_missing(key) if val is Missing: # Variable not in the template context — check if a resolver callback diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index d609e22ac2..3cc4dc7009 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import climate, sensor @@ -70,6 +72,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION = "humidity_control_dehumidify_action" @@ -124,7 +127,12 @@ PRESET_CONFIG_SCHEMA = cv.Schema( ) -def validate_temperature_preset(preset, root_config, name, requirements): +def validate_temperature_preset( + preset: ConfigType, + root_config: ConfigType, + name: str, + requirements: dict[str, list[str]], +) -> None: # verify temperature settings for the provided preset / default / away configuration for config_temp, req_actions in requirements.items(): for req_action in req_actions: @@ -140,7 +148,7 @@ def validate_temperature_preset(preset, root_config, name, requirements): ) -def generate_comparable_preset(config, name): +def generate_comparable_preset(config: ConfigType, name: str) -> str: comparable_preset = f"{CONF_PRESET}:\n - {CONF_NAME}: {name}\n" if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: @@ -151,7 +159,7 @@ def generate_comparable_preset(config, name): return comparable_preset -def validate_heat_cool_mode(value) -> list: +def validate_heat_cool_mode(value: Any) -> list: """Validate heat_cool_mode - accepts either True or an automation.""" if value is True: # Convert True to empty automation list @@ -164,7 +172,7 @@ def validate_heat_cool_mode(value) -> list: return automation.validate_automation(single=True)(value) -def validate_thermostat(config): +def validate_thermostat(config: ConfigType) -> ConfigType: # verify corresponding action(s) exist(s) for any defined climate mode or action requirements = { CONF_HEAT_COOL_MODE: [ @@ -681,7 +689,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index b692b858a3..29a939c523 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -41,13 +43,13 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -71,7 +73,7 @@ WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) -async def waveshare_io_pin_to_code(config): +async def waveshare_io_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py index 9af9ce7e4b..7438769928 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE +from esphome.types import ConfigType from .. import ( CONF_WAVESHARE_IO_CH32V003_ID, @@ -23,7 +24,7 @@ DUTY_DEFAULT_MIN = 1 DUTY_DEFAULT_MAX = 247 -def validate_pwm_limits(config): +def validate_pwm_limits(config: ConfigType) -> ConfigType: """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py index 1e060bdfe4..8ec2702da6 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import ( CONF_WAVESHARE_IO_CH32V003_ID, @@ -46,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) await cg.register_component(var, config) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index b2c0ea14ad..a50c14a2f7 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -4,6 +4,7 @@ import base64 import gzip import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import web_server_base @@ -39,6 +40,7 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -128,7 +130,7 @@ def validate_ota(config: ConfigType) -> ConfigType: _ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") -def validate_origin(value: str) -> str: +def validate_origin(value: Any) -> str: # "*" is the wildcard that allows any origin. if value == "*": return value @@ -306,7 +308,7 @@ CONFIG_SCHEMA = cv.All( ) -def add_sorting_groups(web_server_var, config): +def add_sorting_groups(web_server_var: MockObj, config: list[ConfigType]) -> None: for group in config: sorting_groups[group[CONF_ID]] = group[CONF_NAME] group_sorting_weight = group.get(CONF_SORTING_WEIGHT, 50) @@ -317,7 +319,7 @@ def add_sorting_groups(web_server_var, config): ) -async def add_entity_config(entity, config): +async def add_entity_config(entity: MockObj, config: ConfigType) -> None: web_server = await cg.get_variable(config[CONF_WEB_SERVER_ID]) sorting_weight = config.get(CONF_SORTING_WEIGHT, 50) sorting_group_hash = hash(config.get(CONF_SORTING_GROUP_ID)) @@ -332,7 +334,7 @@ async def add_entity_config(entity, config): ) -def build_index_html(config) -> str: +def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) js_include = config.get(CONF_JS_INCLUDE) @@ -366,7 +368,7 @@ def add_resource_as_progmem( @coroutine_with_priority(CoroPriority.WEB) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 260e6aea6d..03a5c2ca9b 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -80,7 +80,7 @@ FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) From e83439eaaeed12653926473ff9fbfcbc168254f2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:07:10 +1200 Subject: [PATCH 178/470] [core] Add type annotations to component Python (7/11) (#18344) --- esphome/components/as5600/__init__.py | 24 ++++++----- esphome/components/as5600/sensor/__init__.py | 3 +- esphome/components/audio/__init__.py | 17 ++++---- esphome/components/duty_time/sensor.py | 40 ++++++++++++++++--- esphome/components/esp32_hosted/__init__.py | 10 ++--- esphome/components/mixer/speaker/__init__.py | 16 ++++++-- esphome/components/rc522/__init__.py | 4 +- esphome/components/rc522/binary_sensor.py | 7 +++- .../components/resampler/speaker/__init__.py | 11 +++-- esphome/components/rtttl/__init__.py | 28 ++++++++++--- esphome/components/scd4x/sensor.py | 19 +++++++-- esphome/components/sen5x/sensor.py | 13 +++++- esphome/components/sendspin/__init__.py | 6 +-- .../components/sendspin/sensor/__init__.py | 4 +- esphome/components/sound_level/sensor.py | 12 +++++- esphome/components/sps30/sensor.py | 12 +++++- esphome/components/sx127x/__init__.py | 24 ++++++++--- .../sx127x/packet_transport/__init__.py | 3 +- esphome/components/tm1651/__init__.py | 40 ++++++++++++++++--- esphome/components/ufire_ec/sensor.py | 19 +++++++-- esphome/components/ufire_ise/sensor.py | 26 ++++++++++-- 21 files changed, 261 insertions(+), 77 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index c05e556376..780712c3bd 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c @@ -11,6 +14,7 @@ from esphome.const import ( CONF_RANGE, CONF_WATCHDOG, ) +from esphome.types import ConfigType CODEOWNERS = ["@ammmze"] DEPENDENCIES = ["i2c"] @@ -72,13 +76,13 @@ POSITION_TO_ANGLE = 360 / RESOLUTION MIN_RANGE = round(18 * ANGLE_TO_POSITION) -def angle(min=-360, max=360): +def angle(min: float = -360, max: float = 360) -> Callable[[Any], Any]: return cv.All( cv.float_with_unit("angle", "(°|deg)"), cv.float_range(min=min, max=max) ) -def angle_to_position(value, min=-360, max=360): +def angle_to_position(value: Any, min: float = -360, max: float = 360) -> int: try: value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION @@ -86,17 +90,17 @@ def angle_to_position(value, min=-360, max=360): raise cv.Invalid(f"When using angle, {e.error_message}") from e -def percent_to_position(value): +def percent_to_position(value: Any) -> int: value = cv.possibly_negative_percentage(value) return (RESOLUTION + round(value * RESOLUTION)) % RESOLUTION -def position(min=-MAX_POSITION, max=MAX_POSITION): +def position(min: int = -MAX_POSITION, max: int = MAX_POSITION) -> Callable[[Any], Any]: """Validate that the config option is a position. Accepts integers, degrees, or percentage (of 360 degrees). """ - def validator(value): + def validator(value: Any) -> int: if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) @@ -112,7 +116,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): return validator -def position_range(): +def position_range() -> Callable[[Any], Any]: """Validate that value given is a valid range for the device. A valid range is one of the following: - a value of 0 (meaning full range) @@ -129,7 +133,7 @@ def position_range(): zero_validator, ) - def validator(value): + def validator(value: Any) -> Any: is_negative_str = isinstance(value, str) and value.startswith("-") is_negative_num = isinstance(value, (float, int)) and value < 0 if is_negative_str or is_negative_num: @@ -139,13 +143,13 @@ def position_range(): return validator -def has_valid_range_config(): +def has_valid_range_config() -> Callable[[ConfigType], ConfigType]: """Validate that that the config start + end position results in a valid positional range, which must be >= 18degrees """ range_validator = position_range() - def validator(config): + def validator(config: ConfigType) -> ConfigType: # if we don't have an end position, then there is nothing to do if CONF_END_POSITION not in config: return config @@ -203,7 +207,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index cf67a3f203..847b89f121 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import AS5600Component, as5600_ns @@ -77,7 +78,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_AS5600_ID]) await cg.register_component(var, config) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1c522cbb5d..277df0506a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any import esphome.codegen as cg from esphome.components.esp32 import ( @@ -15,6 +17,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["ring_buffer"] CODEOWNERS = ["@kahrendt"] @@ -125,10 +128,10 @@ CONF_THREADSAFE = "threadsafe" _MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True) -def _maybe_empty_codec(schema): +def _maybe_empty_codec(schema: cv.Schema) -> Callable[[Any], Any]: """Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict.""" - def validator(value): + def validator(value: Any) -> Any: if value is None: value = {} return schema(value) @@ -200,14 +203,14 @@ def set_stream_limits( max_channels: int = cv.UNDEFINED, min_sample_rate: int = cv.UNDEFINED, max_sample_rate: int = cv.UNDEFINED, -): +) -> Callable[[ConfigType], None]: """Sets the limits for the audio stream that audio component can handle When the component sinks audio (e.g., a speaker), these indicate the limits to the audio it can receive. When the component sources audio (e.g., a microphone), these indicate the limits to the audio it can send. """ - def set_limits_in_config(config): + def set_limits_in_config(config: ConfigType) -> None: if min_bits_per_sample is not cv.UNDEFINED: config[CONF_MIN_BITS_PER_SAMPLE] = min_bits_per_sample if max_bits_per_sample is not cv.UNDEFINED: @@ -233,7 +236,7 @@ def final_validate_audio_schema( sample_rate: int = cv.UNDEFINED, enabled_channels: list[int] = cv.UNDEFINED, audio_device_issue: bool = False, -): +) -> cv.Schema: """Validates audio compatibility when passed between different components. The component derived from ``AUDIO_COMPONENT_SCHEMA`` should call ``set_stream_limits`` in a validator to specify its compatible settings @@ -251,7 +254,7 @@ def final_validate_audio_schema( audio_device_issue (bool, optional): Format the error message to indicate the problem is in the configuration for the ``audio_device`` component. Defaults to False. """ - def validate_audio_compatiblity(audio_config): + def validate_audio_compatiblity(audio_config: ConfigType) -> ConfigType: audio_schema = {} if bits_per_sample is not cv.UNDEFINED: @@ -329,7 +332,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N add_idf_sdkconfig_option(internal_key, True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) include_builtin_idf_component("esp_http_client") diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 456859f8e4..6d878a80a5 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -19,6 +19,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_LAST_TIME = "last_time" @@ -66,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) @@ -93,7 +96,12 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( @register_action( "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_start_to_code(config, action_id, template_arg, args): +async def sensor_runtime_start_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -102,7 +110,12 @@ async def sensor_runtime_start_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): +async def sensor_runtime_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -111,7 +124,12 @@ async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): +async def sensor_runtime_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -120,7 +138,12 @@ async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): @register_condition( "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @@ -128,6 +151,11 @@ async def duty_time_is_running_to_code(config, condition_id, template_arg, args) @register_condition( "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_not_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_not_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7dc61ce382..ab9455250c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -64,7 +64,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_sdio(config): +def _validate_sdio(config: ConfigType) -> ConfigType: if config[CONF_BUS_WIDTH] == 4: for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN): if pin not in config: @@ -98,7 +98,7 @@ SPI_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_spi(config): +def _validate_spi(config: ConfigType) -> ConfigType: variant = config[CONF_VARIANT] defaults = _SPI_VARIANT_DEFAULTS.get(variant, _SPI_DEFAULT) @@ -141,7 +141,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -def _configure_sdio(config): +def _configure_sdio(config: ConfigType) -> None: slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", @@ -183,7 +183,7 @@ def _configure_sdio(config): ) -def _configure_spi(config): +def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) # SPI mode is set via per-variant choice options variant = config[CONF_VARIANT] @@ -231,7 +231,7 @@ def _configure_spi(config): esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: add_define("USE_ESP32_HOSTED") transport = config[CONF_TYPE] transport_prefix = "SDIO" if transport == "sdio" else "SPI" diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 47164a9997..a3746c019a 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,8 +15,11 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -48,7 +51,7 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( ) -def _validate_source_speaker(config): +def _validate_source_speaker(config: ConfigType) -> ConfigType: fconf = fv.full_config.get() # Get ID for the output speaker and add it to the source speakers config to easily inherit properties @@ -70,7 +73,7 @@ def _validate_source_speaker(config): return config -def _validate_output_speaker(config): +def _validate_output_speaker(config: ConfigType) -> ConfigType: audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, @@ -112,7 +115,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -161,7 +164,12 @@ async def to_code(config): ), synchronous=True, ) -async def ducking_set_to_code(config, action_id, template_arg, args): +async def ducking_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) decibel_reduction = await cg.templatable( diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index ce0d408c04..e9e8dd7b73 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -8,6 +8,8 @@ from esphome.const import ( CONF_RESET_PIN, CONF_TRIGGER_ID, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] AUTO_LOAD = ["binary_sensor"] @@ -38,7 +40,7 @@ RC522_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -async def setup_rc522(var, config): +async def setup_rc522(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if CONF_RESET_PIN in config: diff --git a/esphome/components/rc522/binary_sensor.py b/esphome/components/rc522/binary_sensor.py index 87f81c2223..f295b75df7 100644 --- a/esphome/components/rc522/binary_sensor.py +++ b/esphome/components/rc522/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_RC522_ID, RC522, rc522_ns DEPENDENCIES = ["rc522"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(RC522BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_RC522_ID]) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index ea080adc6b..7de468cb50 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import audio, psram, speaker import esphome.config_validation as cv @@ -13,6 +15,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -27,7 +30,7 @@ CONF_TAPS = "taps" PASSTHROUGH = "passthrough" -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=16, max_bits_per_sample=32, @@ -36,7 +39,7 @@ def _set_stream_limits(config): return config -def _validate_audio_compatibility(config): +def _validate_audio_compatibility(config: ConfigType) -> None: inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) @@ -57,7 +60,7 @@ def _validate_audio_compatibility(config): )(config) -def _validate_taps(taps): +def _validate_taps(taps: Any) -> int: value = cv.int_range(min=16, max=128)(taps) if value % 4 != 0: raise cv.Invalid("Number of taps must be divisible by 4") @@ -88,7 +91,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = _validate_audio_compatibility -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await speaker.register_speaker(var, config) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4880f9ac41..b6c4183586 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -6,7 +6,10 @@ from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -37,7 +40,7 @@ CONFIG_SCHEMA = cv.All( ) -def validate_parent_output_config(value): +def validate_parent_output_config(value: ConfigType) -> None: platform = value.get(CONF_PLATFORM) PWM_GOOD = ["esp8266_pwm", "ledc"] PWM_BAD = [ @@ -78,7 +81,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -110,7 +113,12 @@ async def to_code(config): ), synchronous=True, ) -async def rtttl_play_to_code(config, action_id, template_arg, args): +async def rtttl_play_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_RTTTL], args, cg.std_string) @@ -128,7 +136,12 @@ async def rtttl_play_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def rtttl_stop_to_code(config, action_id, template_arg, args): +async def rtttl_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -143,7 +156,12 @@ async def rtttl_stop_to_code(config, action_id, template_arg, args): } ), ) -async def rtttl_is_playing_to_code(config, condition_id, template_arg, args): +async def rtttl_is_playing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 6f14118660..af3ff3a7af 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -26,6 +26,9 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@sjtrny", "@martgras"] DEPENDENCIES = ["i2c"] @@ -108,7 +111,7 @@ SETTING_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -143,7 +146,12 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( SCD4X_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_frc_to_code(config, action_id, template_arg, args): +async def scd4x_frc_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) @@ -164,7 +172,12 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( SCD4X_RESET_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_reset_to_code(config, action_id, template_arg, args): +async def scd4x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 761a1885ea..e86c8bf899 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -41,6 +43,8 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -115,7 +119,7 @@ def _gas_sensor( ) -def float_previously_pct(value): +def float_previously_pct(value: Any) -> Any: if isinstance(value, str) and "%" in value: raise cv.Invalid( f"The value '{value}' is a percentage. Suggested value: {float(value.strip('%')) / 100}" @@ -284,6 +288,11 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( SEN5X_ACTION_SCHEMA, synchronous=True, ) -async def sen54_fan_to_code(config, action_id, template_arg, args): +async def sen54_fan_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 082639374f..570fd3fadd 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import CORE, ID -from esphome.cpp_generator import TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType # mdns for autodiscovery @@ -219,7 +219,7 @@ async def sendspin_switch_to_code( action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -297,7 +297,7 @@ async def to_code(config: ConfigType) -> None: codecs.append(CODEC_FORMAT_OPUS) codecs.append(CODEC_FORMAT_PCM) - def _audio_format(codec, channels): + def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( AudioSupportedFormatObject, ("codec", codec), diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py index dc9b86c2a3..d6016ed91d 100644 --- a/esphome/components/sendspin/sensor/__init__.py +++ b/esphome/components/sendspin/sensor/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -50,7 +52,7 @@ def _request_roles(config: ConfigType) -> ConfigType: _HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) -def _metadata_schema(**sensor_kwargs): +def _metadata_schema(**sensor_kwargs: Any) -> cv.Schema: """Schema for event-driven numeric metadata sensors (duration/year/track).""" return ( sensor.sensor_schema( diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index 44f31979b4..d217534041 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -11,6 +11,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DECIBEL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -95,7 +98,12 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True ) -async def sound_level_action_to_code(config, action_id, template_arg, args): +async def sound_level_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 40557f2cbd..681166cd3c 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,6 +26,9 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_MICROMETER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@martgras"] DEPENDENCIES = ["i2c"] @@ -120,7 +123,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -197,7 +200,12 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( SPS30_ACTION_SCHEMA, synchronous=True, ) -async def sps30_action_to_code(config, action_id, template_arg, args): +async def sps30_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 8fa7247192..34f2d4122f 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi @@ -5,6 +7,8 @@ from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] @@ -136,7 +140,7 @@ SetModeStandbyAction = sx127x_ns.class_( ) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -146,7 +150,7 @@ def validate_raw_data(value): ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_MODULATION] == "LORA": bws = [ "7_8kHz", @@ -230,7 +234,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -312,7 +316,12 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def no_args_action_to_code(config, action_id, template_arg, args): +async def no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -333,7 +342,12 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( SEND_PACKET_ACTION_SCHEMA, synchronous=True, ) -async def send_packet_action_to_code(config, action_id, template_arg, args): +async def send_packet_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_DATA] diff --git a/esphome/components/sx127x/packet_transport/__init__.py b/esphome/components/sx127x/packet_transport/__init__.py index 2f3a0f6e2b..33204a7d83 100644 --- a/esphome/components/sx127x/packet_transport/__init__.py +++ b/esphome/components/sx127x/packet_transport/__init__.py @@ -6,6 +6,7 @@ from esphome.components.packet_transport import ( ) import esphome.config_validation as cv from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import CONF_SX127X_ID, SX127x, SX127xListener, sx127x_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = transport_schema(SX127xTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) sx127x = await cg.get_variable(config[CONF_SX127X_ID]) cg.add(var.set_parent(sx127x)) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 7d957df3be..c0cc6f1d2c 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ID, CONF_LEVEL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mrtoy-me"] @@ -43,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) @@ -75,7 +78,12 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ), synchronous=True, ) -async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): +async def tm1651_set_brightness_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) @@ -95,7 +103,12 @@ async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def tm1651_set_level_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +128,12 @@ async def tm1651_set_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_percent_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) @@ -129,7 +147,12 @@ async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True, ) -async def output_turn_off_to_code(config, action_id, template_arg, args): +async def output_turn_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -138,7 +161,12 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): @automation.register_action( "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True ) -async def output_turn_on_to_code(config, action_id, template_arg, args): +async def output_turn_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 1d8775ccf0..9d989ad4e6 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MILLISIEMENS_PER_CENTIMETER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) @@ -99,7 +102,12 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( UFIRE_EC_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): +async def ufire_ec_calibrate_probe_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -122,6 +130,11 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( UFIRE_EC_RESET_SCHEMA, synchronous=True, ) -async def ufire_ec_reset_to_code(config, action_id, template_arg, args): +async def ufire_ec_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 23254b2f47..c7e3b6f28d 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PH, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -60,7 +63,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -93,7 +96,12 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): +async def ufire_ise_calibrate_probe_low_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -107,7 +115,12 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): +async def ufire_ise_calibrate_probe_high_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -124,6 +137,11 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent UFIRE_ISE_RESET_SCHEMA, synchronous=True, ) -async def ufire_ise_reset_to_code(config, action_id, template_arg, args): +async def ufire_ise_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) From 545f762568609fa7f57e841852308e6c9f2d7dd4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:08:26 +1200 Subject: [PATCH 179/470] [core] Add type annotations to component Python (8/11) (#18345) --- .../components/atm90e32/button/__init__.py | 3 +- .../components/atm90e32/number/__init__.py | 3 +- esphome/components/atm90e32/sensor.py | 3 +- .../atm90e32/text_sensor/__init__.py | 3 +- esphome/components/bl0940/button/__init__.py | 3 +- esphome/components/bl0940/number/__init__.py | 5 +-- esphome/components/bl0940/sensor.py | 19 ++++++----- esphome/components/bm8563/time.py | 26 ++++++++++++--- esphome/components/event/__init__.py | 24 ++++++++++---- esphome/components/ld2410/__init__.py | 12 +++++-- esphome/components/ld2410/binary_sensor.py | 3 +- esphome/components/ld2410/button/__init__.py | 3 +- esphome/components/ld2410/number/__init__.py | 3 +- esphome/components/ld2410/select/__init__.py | 3 +- esphome/components/ld2410/sensor.py | 3 +- esphome/components/ld2410/switch/__init__.py | 3 +- esphome/components/ld2410/text_sensor.py | 3 +- esphome/components/ld2412/__init__.py | 3 +- esphome/components/ld2412/binary_sensor.py | 3 +- esphome/components/ld2412/button/__init__.py | 3 +- esphome/components/ld2412/number/__init__.py | 3 +- esphome/components/ld2412/select/__init__.py | 3 +- esphome/components/ld2412/sensor.py | 3 +- esphome/components/ld2412/switch/__init__.py | 3 +- esphome/components/ld2412/text_sensor.py | 3 +- esphome/components/ld2420/__init__.py | 3 +- .../ld2420/binary_sensor/__init__.py | 3 +- esphome/components/ld2420/button/__init__.py | 3 +- esphome/components/ld2420/number/__init__.py | 3 +- esphome/components/ld2420/select/__init__.py | 3 +- esphome/components/ld2420/sensor/__init__.py | 3 +- .../components/ld2420/text_sensor/__init__.py | 3 +- esphome/components/ld2450/__init__.py | 3 +- esphome/components/ld2450/binary_sensor.py | 3 +- esphome/components/ld2450/button/__init__.py | 3 +- esphome/components/ld2450/number/__init__.py | 3 +- esphome/components/ld2450/select/__init__.py | 3 +- esphome/components/ld2450/sensor.py | 3 +- esphome/components/ld2450/switch/__init__.py | 3 +- esphome/components/ld2450/text_sensor.py | 3 +- esphome/components/max6956/__init__.py | 23 ++++++++++--- esphome/components/max6956/output/__init__.py | 3 +- esphome/components/max7219digit/display.py | 33 ++++++++++++++++--- esphome/components/micronova/__init__.py | 10 ++++-- .../components/micronova/button/__init__.py | 3 +- .../components/micronova/number/__init__.py | 3 +- .../components/micronova/sensor/__init__.py | 3 +- .../components/micronova/switch/__init__.py | 3 +- .../micronova/text_sensor/__init__.py | 3 +- esphome/components/pipsolar/__init__.py | 3 +- .../pipsolar/binary_sensor/__init__.py | 3 +- .../components/pipsolar/output/__init__.py | 12 +++++-- .../components/pipsolar/sensor/__init__.py | 3 +- .../components/pipsolar/switch/__init__.py | 3 +- .../pipsolar/text_sensor/__init__.py | 3 +- esphome/components/text/__init__.py | 30 ++++++++++------- .../components/text/text_sensor/__init__.py | 3 +- 57 files changed, 238 insertions(+), 97 deletions(-) diff --git a/esphome/components/atm90e32/button/__init__.py b/esphome/components/atm90e32/button/__init__.py index 19f62ccfbd..274cce6adb 100644 --- a/esphome/components/atm90e32/button/__init__.py +++ b/esphome/components/atm90e32/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -67,7 +68,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION): diff --git a/esphome/components/atm90e32/number/__init__.py b/esphome/components/atm90e32/number/__init__.py index 848680b875..9c2865dde3 100644 --- a/esphome/components/atm90e32/number/__init__.py +++ b/esphome/components/atm90e32/number/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if voltage_cfg := config.get(CONF_REFERENCE_VOLTAGE): diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index dc46138add..38b24c7cf6 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -41,6 +41,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from . import atm90e32_ns @@ -191,7 +192,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_instance_id(str(config[CONF_ID]))) await cg.register_component(var, config) diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor/__init__.py index ab96f6c207..30585cb873 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C +from esphome.types import ConfigType from ..sensor import ATM90E32Component @@ -34,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if phase_cfg := config.get(CONF_PHASE_STATUS): diff --git a/esphome/components/bl0940/button/__init__.py b/esphome/components/bl0940/button/__init__.py index 04d11e6e30..e87a647392 100644 --- a/esphome/components/bl0940/button/__init__.py +++ b/esphome/components/bl0940/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/number/__init__.py b/esphome/components/bl0940/number/__init__.py index 92ab2837b3..b5a66e682a 100644 --- a/esphome/components/bl0940/number/__init__.py +++ b/esphome/components/bl0940/number/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -27,7 +28,7 @@ CalibrationNumber = bl0940_ns.class_( ) -def validate_min_max(config): +def validate_min_max(config: ConfigType) -> ConfigType: if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: raise cv.Invalid("max_value must be greater than min_value") return config @@ -69,7 +70,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Get the BL0940 component instance bl0940 = await cg.get_variable(config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 96445d5c38..7e6403c3bc 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import bl0940_ns @@ -69,27 +70,29 @@ DEFAULT_BL0940_LEGACY_EREF = 3.6e6 / 297 # methods to calculate voltage and current reference values -def calculate_voltage_reference(vref, r_one, r_two): +def calculate_voltage_reference(vref: float, r_one: float, r_two: float) -> float: # formula: 79931 / Vref * (R1 * 1000) / (R1 + R2) return 79931 / vref * (r_one * 1000) / (r_one + r_two) -def calculate_current_reference(vref, r_shunt): +def calculate_current_reference(vref: float, r_shunt: float) -> float: # formula: 324004 * RL / Vref return 324004 * r_shunt / vref -def calculate_power_reference(voltage_reference, current_reference): +def calculate_power_reference( + voltage_reference: float, current_reference: float +) -> float: # calculate power reference based on voltage and current reference return voltage_reference * current_reference * 4046 / 324004 / 79931 -def calculate_energy_reference(power_reference): +def calculate_energy_reference(power_reference: float) -> float: # formula: power_reference * 3600000 / (1638.4 * 256) return power_reference * 3600000 / (1638.4 * 256) -def validate_legacy_mode(config): +def validate_legacy_mode(config: ConfigType) -> ConfigType: # Only allow schematic calibration options if legacy_mode is False if config.get(CONF_LEGACY_MODE, True): forbidden = [ @@ -106,7 +109,7 @@ def validate_legacy_mode(config): return config -def set_command_defaults(config): +def set_command_defaults(config: ConfigType) -> ConfigType: # Set defaults for read_command and write_command based on legacy_mode legacy = config.get(CONF_LEGACY_MODE, True) if legacy: @@ -118,7 +121,7 @@ def set_command_defaults(config): return config -def set_reference_values(config): +def set_reference_values(config: ConfigType) -> ConfigType: # Set default reference values based on legacy_mode if config.get(CONF_LEGACY_MODE, True): config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF) @@ -223,7 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/bm8563/time.py b/esphome/components/bm8563/time.py index ba264f00bf..5ef162bb7c 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -35,7 +38,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def bm8563_write_time_to_code(config, action_id, template_arg, args): +async def bm8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -52,7 +60,12 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_start_timer_to_code(config, action_id, template_arg, args): +async def bm8563_start_timer_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) @@ -70,13 +83,18 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_read_time_to_code(config, action_id, template_arg, args): +async def bm8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index e205e4b910..881107b713 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@nohat"] IS_PLATFORM_COMPONENT = True @@ -93,7 +94,9 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("event") -async def setup_event_core_(var, config, *, event_types: list[str]): +async def setup_event_core_( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_event_types(event_types)) @@ -108,7 +111,9 @@ async def setup_event_core_(var, config, *, event_types: list[str]): await web_server.add_entity_config(var, web_server_config) -async def register_event(var, config, *, event_types: list[str]): +async def register_event( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("event", config) @@ -116,7 +121,7 @@ async def register_event(var, config, *, event_types: list[str]): await setup_event_core_(var, config, event_types=event_types) -async def new_event(config, *, event_types: list[str]): +async def new_event(config: ConfigType, *, event_types: list[str]) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_event(var, config, event_types=event_types) return var @@ -133,7 +138,12 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( @automation.register_action( "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True ) -async def event_fire_to_code(config, action_id, template_arg, args): +async def event_fire_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) @@ -142,5 +152,5 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(event_ns.using) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 360e56330a..19786f38d3 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_THROTTLE, CONF_TIMEOUT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -69,7 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -102,7 +105,12 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( BLUETOOTH_PASSWORD_SET_SCHEMA, synchronous=True, ) -async def bluetooth_password_set_to_code(config, action_id, template_arg, args): +async def bluetooth_password_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_PASSWORD], args, cg.std_string) diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index fb5b5cabff..2b68733532 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -46,7 +47,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index fa6f31ee25..59a9558331 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -44,7 +45,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 01dbcc785d..3500d704a1 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if timeout_config := config.get(CONF_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 9c4f654aa1..e89e3d5997 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): s = await select.new_select( diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 459018e263..ca42b3a1d3 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_CENTIMETER, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -155,7 +156,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if moving_distance_config := config.get(CONF_MOVING_DISTANCE): sens = await sensor.new_sensor(moving_distance_config) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 4276b28a71..6d8053ddd6 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): s = await switch.new_switch(engineering_mode_config) diff --git a/esphome/components/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index a34c8ec0d2..25c61a4825 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index e701d0bda9..82db319861 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@Rihan9"] @@ -40,7 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 98fa5965cd..80cff014c0 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if dynamic_background_correction_status_config := config.get( CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index e0ca285265..5a1ea2e6a5 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index b6e1c8d039..1a81c330ad 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): n = await number.new_number( diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index a54cd700ed..02ecf2c30f 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index f562afe0ee..0b6e676931 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -156,7 +157,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): sens = await sensor.new_sensor(detection_distance_config) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index 7a87e9e483..e7f71222fd 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index 22fba5193e..c8e9f42ef3 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 71a5fa13e4..5a5aabeba0 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 5ebc4a9f63..76b42c0362 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_HAS_TARGET in config: diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py index dfeb121c91..cfcffd0922 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -50,7 +51,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if apply_config := config.get(CONF_APPLY_CONFIG): b = await button.new_button(apply_config) diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index a2637b7b06..448639c911 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_TIMELAPSE, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -113,7 +114,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index b9059c120f..cd66064e47 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if operating_mode_config := config.get(CONF_OPERATING_MODE): sel = await select.new_select( diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index 97acdabd7b..f98d63585b 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_MOVING_DISTANCE in config: diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index 14d982e5fb..cee8f25c1f 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_FW_VERSION in config: diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 585c9f7bf5..4c37f4fcd1 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -49,7 +50,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 89e629253a..779d151fd9 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -39,7 +40,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 682487d750..42cadd2052 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 799c0703f2..4f242076d6 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIMETER, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -78,7 +79,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index 4f237dc94f..d91b42426a 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index ae13900e7a..40462e202d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -226,7 +227,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 0c0c92377b..084f79ee1b 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 4e5d7d419b..a8b978ef48 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_CHIP, ICON_SIGN_DIRECTION, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index e9fae4cceb..5e45d71899 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,6 +11,9 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -54,7 +57,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -62,7 +65,7 @@ async def to_code(config): cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -87,7 +90,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) -async def max6956_pin_to_code(config): +async def max6956_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MAX6956]) @@ -114,7 +117,12 @@ async def max6956_pin_to_code(config): ), synchronous=True, ) -async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_global_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) @@ -136,7 +144,12 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, ), synchronous=True, ) -async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable( diff --git a/esphome/components/max6956/output/__init__.py b/esphome/components/max6956/output/__init__.py index 352ba04a95..f92bbb762a 100644 --- a/esphome/components/max6956/output/__init__.py +++ b/esphome/components/max6956/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_MAX6956, MAX6956, max6956_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MAX6956]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index df2423b0d0..54711263dd 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,6 +10,9 @@ from esphome.const import ( CONF_NUM_CHIPS, CONF_STATE, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@rspaargaren"] DEPENDENCIES = ["spi"] @@ -84,7 +87,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) @@ -144,7 +147,12 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_invert_to_code(config, action_id, template_arg, args): +async def max7219digit_invert_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -164,7 +172,12 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_visible_to_code(config, action_id, template_arg, args): +async def max7219digit_visible_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -184,7 +197,12 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_reverse_to_code(config, action_id, template_arg, args): +async def max7219digit_reverse_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -209,7 +227,12 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( MAX7219_INTENSITY_SCHEMA, synchronous=True, ) -async def max7219digit_intensity_to_code(config, action_id, template_arg, args): +async def max7219digit_intensity_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index b462352229..ff06d0b913 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -7,6 +7,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jorre05", "@edenhaus"] @@ -63,7 +65,7 @@ def MICRONOVA_ADDRESS_SCHEMA( default_memory_location: int | None = None, default_memory_address: int | None = None, is_polling_component: bool, -): +) -> cv.Schema: location_key = ( cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) if default_memory_location is not None @@ -91,7 +93,9 @@ def register_micronova_writer() -> None: _get_data().has_writer = True -async def to_code_micronova_listener(mv, var, config): +async def to_code_micronova_listener( + mv: MockObj, var: MockObj, config: ConfigType +) -> None: _get_data().listener_count += 1 await cg.register_component(var, config) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) @@ -100,7 +104,7 @@ async def to_code_micronova_listener(mv, var, config): cg.add(mv.register_micronova_listener(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 63b127e63d..68b5b9aca6 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MEMORY_ADDRESS, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index bcc972c5a9..d33bb150ce 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_STEP, DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -56,7 +57,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index e53c49aca5..6091718d65 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -125,7 +126,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) for key, divisor in { diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index e149ee3ce3..1f57497ad7 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 33d0779eae..d6b94c437f 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_state_config := config.get(CONF_STOVE_STATE): diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index e3966aa2cc..b404409145 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andreashergert1984"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/pipsolar/binary_sensor/__init__.py b/esphome/components/pipsolar/binary_sensor/__init__.py index 5bcf1f75ee..62c0ed8538 100644 --- a/esphome/components/pipsolar/binary_sensor/__init__.py +++ b/esphome/components/pipsolar/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -132,7 +133,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: if type in config: diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index d1ea981589..62e6d0f113 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -75,7 +78,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type, (_, command) in TYPES.items(): @@ -100,7 +103,12 @@ async def to_code(config): ), synchronous=True, ) -async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): +async def output_pipsolar_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 88c6566d63..5a697157b7 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT_AMPS, UNIT_WATT, ) +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -325,7 +326,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: diff --git a/esphome/components/pipsolar/switch/__init__.py b/esphome/components/pipsolar/switch/__init__.py index 11dbc91110..2b493eac95 100644 --- a/esphome/components/pipsolar/switch/__init__.py +++ b/esphome/components/pipsolar/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -36,7 +37,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type, (on, off) in TYPES.items(): diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index 90ce3a7e55..cc7477395b 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -31,7 +32,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 06b5a10892..e010e2c292 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,13 +13,14 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] IS_PLATFORM_COMPONENT = True @@ -90,13 +91,13 @@ def text_schema( @setup_entity("text") async def setup_text_core_( - var, - config, + var: MockObj, + config: ConfigType, *, min_length: int | None, max_length: int | None, pattern: str | None, -): +) -> None: cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) if pattern is not None: @@ -117,13 +118,13 @@ async def setup_text_core_( async def register_text( - var, - config, + var: MockObj, + config: ConfigType, *, min_length: int | None = 0, max_length: int | None = 255, pattern: str | None = None, -): +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("text", config) @@ -134,12 +135,12 @@ async def register_text( async def new_text( - config, + config: ConfigType, *, min_length: int | None = 0, max_length: int | None = 255, pattern: str | None = None, -): +) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_text( var, config, min_length=min_length, max_length=max_length, pattern=pattern @@ -148,7 +149,7 @@ async def new_text( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(text_ns.using) @@ -169,7 +170,12 @@ OPERATION_BASE_SCHEMA = cv.Schema( ), synchronous=True, ) -async def text_set_to_code(config, action_id, template_arg, args): +async def text_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.std_string) diff --git a/esphome/components/text/text_sensor/__init__.py b/esphome/components/text/text_sensor/__init__.py index 5e45f10193..ab0e9bdcdc 100644 --- a/esphome/components/text/text_sensor/__init__.py +++ b/esphome/components/text/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_SOURCE_ID +from esphome.types import ConfigType from .. import Text, text_ns @@ -19,7 +20,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: source = await cg.get_variable(config[CONF_SOURCE_ID]) var = await text_sensor.new_text_sensor(config, source) await cg.register_component(var, config) From 7fe4399b945e242cf07ac8f7aa830976d1c850d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:11:53 -0500 Subject: [PATCH 180/470] [esp32] Grow the default IDF component exclusion list (#18536) --- esphome/components/ac_dimmer/output.py | 6 ++ esphome/components/esp32/__init__.py | 25 ++++++++- esphome/components/http_request/__init__.py | 5 +- esphome/components/i2c/__init__.py | 5 ++ esphome/components/ledc/output.py | 4 ++ esphome/components/mqtt/__init__.py | 2 + esphome/components/nextion/display.py | 2 + esphome/components/web_server_idf/__init__.py | 8 ++- .../esp32/config/exclusion_reincludes.yaml | 20 +++++++ .../exclusion_reincludes_http_request.yaml | 14 +++++ .../config/exclusion_reincludes_mqtt.yaml | 14 +++++ .../config/exclusion_reincludes_nextion.yaml | 20 +++++++ .../exclusion_reincludes_web_server.yaml | 14 +++++ tests/component_tests/esp32/test_esp32.py | 56 +++++++++++++++++++ 14 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 1f35095e0e..48bef2c317 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -49,6 +49,12 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the gptimer driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_gptimer") + if CORE.is_esp8266: # ac_dimmer uses setTimer1Callback which requires the waveform generator from esphome.components.esp8266.const import require_waveform diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6e0890751..f1f039922a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -204,18 +204,32 @@ COMPILER_OPTIMIZATIONS = { # ESP-IDF components excluded by default to reduce compile time. # Components can be re-enabled by calling include_builtin_idf_component() in to_code(). # -# Cannot be excluded (dependencies of required components): -# - "console": espressif/mdns unconditionally depends on it -# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain +# Note: excluding a component only removes it from the initial build set. +# ESP-IDF's requirement expansion adds an excluded component back when any +# component still in the build REQUIRES it (e.g. espressif/mdns pulls +# "console" back in, esp_http_client pulls "tcp_transport" back in), so +# exclusions here are safe for such components and simply become no-ops in +# builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( + "app_trace", # CPU trace/SystemView support - unused by ESPHome "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers + "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf "esp_adc", # ADC driver - only needed by adc component + "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself "esp_driver_mcpwm", # MCPWM driver - ESPHome doesn't use motor control PWM "esp_driver_pcnt", # PCNT driver - only needed by pulse_counter, hlw8012 components "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus + "esp_driver_sdio", # SDIO device-mode driver - unused by ESPHome + "esp_driver_sdm", # Sigma-delta modulation driver - unused by ESPHome + "esp_driver_sdmmc", # SD/MMC host driver - unused by ESPHome + "esp_driver_sdspi", # SD-over-SPI driver - unused by ESPHome "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component @@ -227,11 +241,16 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "json", # cJSON library - ESPHome uses ArduinoJson instead "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation "openthread", # Thread protocol - only needed by openthread component "perfmon", # Xtensa performance monitor - ESPHome has its own debug component + "protobuf-c", # Protobuf runtime - only used by provisioning components (also excluded) "protocomm", # Protocol communication for provisioning - unused by ESPHome + "rt", # POSIX realtime extensions - unused by ESPHome + "sdmmc", # SD/MMC protocol layer - only used by SD drivers and fatfs (also excluded) "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "tcp_transport", # Transport layer - esp_http_client/mqtt pull it back when re-included "ulp", # ULP coprocessor - not currently used by any ESPHome component "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index afc39e06a8..8a5aae022a 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -170,8 +170,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). + # esp-tls is re-enabled too because http_request includes + # directly and esp_http_client only pulls it in as a private dependency. esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 7b163d065e..94aad4d019 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -284,6 +284,11 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the I2C driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2c") if CORE.is_host: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 637e607b6d..e5e7c3dcbe 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import output +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -62,6 +63,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: + # Re-enable the LEDC driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_ledc") + gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 98ca23b60b..9178bc79e5 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -361,6 +361,8 @@ async def to_code(config): add_idf_component(name="espressif/mqtt", ref="1.0.0") else: include_builtin_idf_component("mqtt") + # mqtt_client.h drags in esp_tls types; esp-tls is excluded by default + include_builtin_idf_component("esp-tls") cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 4ab123c354..3f5ba94b40 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -290,7 +290,9 @@ async def to_code(config): if CORE.is_esp32: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # and esp-tls, whose sdkconfig options below need the component present esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 74a9d657a6..adf21ddc49 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,4 +1,7 @@ -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + include_builtin_idf_component, +) import esphome.config_validation as cv CODEOWNERS = ["@dentra"] @@ -12,3 +15,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + # Re-enable esp-tls (excluded by default to save compile time); + # web_server_idf.cpp includes for digest auth + include_builtin_idf_component("esp-tls") diff --git a/tests/component_tests/esp32/config/exclusion_reincludes.yaml b/tests/component_tests/esp32/config/exclusion_reincludes.yaml new file mode 100644 index 0000000000..ba5bf17688 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + sda: 21 + scl: 22 + +output: + - platform: ledc + id: ledc_out + pin: 25 + - platform: ac_dimmer + id: dimmer_out + gate_pin: 26 + zero_cross_pin: 27 diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml new file mode 100644 index 0000000000..5adfd66b00 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: false diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml new file mode 100644 index 0000000000..c509942635 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +mqtt: + broker: "10.0.0.1" diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml new file mode 100644 index 0000000000..3c6e527b09 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +display: + - platform: nextion + tft_url: "http://10.0.0.1/display.tft" diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml new file mode 100644 index 0000000000..6041bffee6 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +web_server: + version: 3 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1fd835076d..7208318d3a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -236,6 +236,62 @@ def test_esp32_configuration_errors( FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) +@pytest.mark.parametrize( + ("config_file", "reincluded"), + [ + pytest.param( + "exclusion_reincludes.yaml", + ("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"), + id="i2c_ledc_ac_dimmer", + ), + # esp-tls has three owners; a per-owner config makes a dropped + # re-include from any single one fail the test. + pytest.param( + "exclusion_reincludes_http_request.yaml", + ("esp-tls", "esp_http_client"), + id="http_request", + ), + pytest.param( + # "mqtt" itself is deliberately not asserted: on IDF >= 6.0 it + # is a managed component and never leaves the exclusion set. + "exclusion_reincludes_mqtt.yaml", + ("esp-tls",), + id="mqtt", + ), + pytest.param( + "exclusion_reincludes_web_server.yaml", + ("esp-tls",), + id="web_server_idf", + ), + pytest.param( + "exclusion_reincludes_nextion.yaml", + ("esp-tls", "esp_http_client"), + id="nextion", + ), + ], +) +def test_default_exclusions_reincluded_by_owning_components( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + reincluded: tuple[str, ...], +) -> None: + """Components whose IDF driver is excluded by default must re-include it + during codegen; a dropped include_builtin_idf_component() call would only + surface as a missing-header failure in a full compile job.""" + from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS + + generate_main(component_config_path(config_file)) + excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + + for name in reincluded: + assert name not in excluded, f"{name} should have been re-included" + + # Components no part of this config touches stay excluded. + assert "unity" in excluded + assert "fatfs" in excluded + + def test_execute_from_psram_s3_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From a3ea77c2f1206939c0f59aa90870485270998b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:32:15 -0500 Subject: [PATCH 181/470] [core] Keep templated !include filenames as strings so Windows path normalization cannot corrupt them (#18549) --- esphome/components/substitutions/__init__.py | 5 +- esphome/yaml_util.py | 28 ++++++---- tests/unit_tests/test_bundle.py | 56 +++++++++++++++++++- tests/unit_tests/test_substitutions.py | 19 +++++++ tests/unit_tests/test_yaml_util.py | 27 +++++++++- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index b4fcf36c9e..5ef7a699eb 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -363,13 +363,12 @@ def resolve_include( an explicit non-goal here. """ original = include.file - original_str = str(original) filename = str( _expand_substitutions( - original_str, path + ["file"], context_vars, strict_undefined, errors + original, path + ["file"], context_vars, strict_undefined, errors ) ) - substituted = filename != original_str + substituted = filename != original if substituted: include = include.with_file(filename) try: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index d3c6caf60b..c280e550c9 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -231,18 +231,22 @@ class IncludeFile: def __init__( self, parent_file: Path, - file: Path | str, + file: str, vars: dict[str, Any] | None, yaml_loader: Callable[[Path], Any], ) -> None: self.parent_file = parent_file - self.file = Path(file) + # The raw include text may be a substitution/Jinja expression, so it + # must never round-trip through Path(): on Windows, WindowsPath str() + # rewrites "/" to "\", which Jinja then decodes as escapes like + # "\b" -> backspace (issue #18545). + self.file = file self.vars = vars self.yaml_loader = yaml_loader self._content: Any = _UNSET def __repr__(self) -> str: - return f"IncludeFile({self.file.as_posix()})" + return f"IncludeFile({self.file})" def load(self) -> Any: """Load and cache the included file content. @@ -258,15 +262,15 @@ class IncludeFile: raise Invalid( f"Cannot load include with unresolved substitutions: {self.file}" ) - self._content = self.yaml_loader(Path(self.parent_file.parent / self.file)) + self._content = self.yaml_loader(self.parent_file.parent / self.file) self._content = add_context(self._content, self.vars) return self._content def has_unresolved_expressions(self) -> bool: """Check if the filename contains substitution variables or Jinja expressions.""" - return has_substitution_or_expression(str(self.file)) + return has_substitution_or_expression(self.file) - def with_file(self, file: Path | str) -> IncludeFile: + def with_file(self, file: str) -> IncludeFile: """Clone this include with *file* as the filename.""" return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader) @@ -313,7 +317,7 @@ def _candidate_include_paths(include: IncludeFile) -> list[Path]: parent_dir = include.parent_file.parent parent_resolved = include.parent_file.resolve() candidates: list[Path] = [] - for pattern in include_candidate_patterns(str(include.file)): + for pattern in include_candidate_patterns(include.file): if "*" in pattern: matches = sorted(_glob_include_candidates(parent_dir, pattern)) else: @@ -362,7 +366,7 @@ def _load_include_candidates( continue expanded_paths.add(candidate) try: - loaded = include.with_file(candidate).load() + loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: # Unlike an unresolved pattern (expected during the discovery # re-parse), a matched on-disk candidate that fails to load is a @@ -794,6 +798,10 @@ class ESPHomeLoaderMixin: file = fields.get("file") if file is None: raise yaml.MarkedYAMLError("Must include 'file'", node.start_mark) + if not isinstance(file, str): + raise yaml.MarkedYAMLError( + "Include 'file' must be a string", node.start_mark + ) vars = fields.get(CONF_VARS) return file, vars @@ -1333,11 +1341,11 @@ class ESPHomeDumper(yaml.SafeDumper): def represent_include_file(self, value): if value.vars: - mapping = {"file": value.file.as_posix(), "vars": value.vars} + mapping = {"file": value.file, "vars": value.vars} return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) - return self.represent_scalar(tag="!include", value=value.file.as_posix()) + return self.represent_scalar(tag="!include", value=value.file) def represent_id(self, value): if is_secret(value.id): diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 29e917fe44..1abc7a3ab8 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -29,8 +29,9 @@ from esphome.bundle import ( read_bundle_manifest, remap_bundle_path, ) +from esphome.components.substitutions import do_substitution_pass from esphome.core import CORE, EsphomeError -from esphome.yaml_util import force_load_include_files +from esphome.yaml_util import force_load_include_files, load_yaml # --------------------------------------------------------------------------- # Helpers @@ -1277,6 +1278,59 @@ def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None: assert "includes/empty.yaml" in paths +@pytest.mark.parametrize("enable_proxy", [True, False]) +def test_bundle_roundtrip_templated_include_with_path_separator( + tmp_path: Path, enable_proxy: bool +) -> None: + r"""The issue-18545 flow: a Jinja !include whose branches contain "/" still + resolves after the bundle is extracted on the build server. + + Windows is the leg that regresses: the raw expression text must survive + verbatim, or its separators get rewritten to "\" and Jinja decodes + sequences like "\b" as string escapes. + """ + config_dir = _setup_config_dir( + tmp_path, + files={ + "includes/boards/board.yaml": ( + "packages:\n" + ' - !include ${ "bluetooth/bluetooth_proxy_single_core.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }\n' + ), + "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml": ( + "bluetooth_proxy:\n active: true\n" + ), + "includes/empty.yaml": "{}\n", + }, + ) + (config_dir / "test.yaml").write_text( + "substitutions:\n" + f" enable_bluetooth_proxy: {str(enable_proxy).lower()}\n" + "esphome:\n name: test\n" + "packages:\n - !include includes/boards/board.yaml\n" + ) + + result = ConfigBundleCreator({}).create_bundle() + bundle_path = tmp_path / "device.esphomebundle.tar.gz" + bundle_path.write_bytes(result.data) + + # Both conditional branches must ship in the bundle. + paths = [f.path for f in result.files] + assert "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml" in paths + assert "includes/empty.yaml" in paths + + # Extract to a fresh directory and resolve the config from there, as a + # remote build server would. + extracted_config = extract_bundle(bundle_path, tmp_path / "remote") + config = do_substitution_pass(load_yaml(extracted_config)) + + board_pkg = config["packages"][0]["packages"][0] + if enable_proxy: + assert board_pkg == {"bluetooth_proxy": {"active": True}} + else: + assert board_pkg == {} + + def test_discover_files_candidate_outside_config_dir_skipped( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index f4063237b1..73c6e496a9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -744,6 +744,25 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_include_filename_jinja_expression_with_path_separator( + tmp_path: Path, +) -> None: + """A jinja !include whose string literals contain "/" resolves correctly (issue #18545).""" + main_file = tmp_path / "main.yaml" + main_file.write_text( + "substitutions:\n" + " enable_bluetooth_proxy: true\n" + "result: !include " + '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }\n' + ) + (tmp_path / "bluetooth").mkdir() + (tmp_path / "bluetooth" / "proxy.yaml").write_text("value: 42\n") + + config = yaml_util.load_yaml(main_file) + config = substitutions.do_substitution_pass(config) + assert config["result"] == {"value": 42} + + def test_raise_first_undefined_logs_extras_at_debug( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e0a81652e3..3bdbd04396 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -701,6 +701,31 @@ def test_include_file_has_unresolved_expressions( assert include.has_unresolved_expressions() == expected +def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None: + """The mapping !include form rejects a non-string 'file' with a clear error.""" + entry = tmp_path / "entry.yaml" + entry.write_text("wifi: !include\n file: [not, a, string]\n") + with pytest.raises(EsphomeError, match="Include 'file' must be a string"): + yaml_util.load_yaml(entry) + + +def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> None: + """A templated filename keeps its verbatim text (issue #18545).""" + parent = tmp_path / "main.yaml" + expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }' + include = yaml_util.IncludeFile(parent, expr, None, lambda _: {}) + assert include.file == expr + assert include.has_unresolved_expressions() + assert repr(include) == f"IncludeFile({expr})" + + +def test_represent_include_file_templated() -> None: + """Dumping a templated IncludeFile emits the raw expression unchanged.""" + expr = '${ "a/b.yaml" if flag else "../c.yaml" }' + include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {}) + assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n" + + def test_include_in_list_context() -> None: """!include of a file returning a list is handled correctly, including when that list itself contains a nested IncludeFile.""" @@ -1051,7 +1076,7 @@ class _StubInclude: ) -> None: # Default parent lives in a nonexistent directory so unresolved # stubs never glob real files during candidate expansion. - self.file = Path(file) + self.file = file self.parent_file = parent_file or Path("/nonexistent/parent.yaml") self._unresolved = unresolved self._load_result = load_result if load_result is not None else {} From 2ab09e1a77227718e1d9318319e8745bcfab01ac Mon Sep 17 00:00:00 2001 From: David van 't Wout Date: Thu, 20 Aug 2026 19:30:33 +0200 Subject: [PATCH 182/470] [core] Add add_cmake_arg (#18498) --- esphome/build_gen/espidf.py | 38 +++++++++------- esphome/build_gen/platformio.py | 11 +++++ esphome/codegen.py | 1 + esphome/components/esp32/__init__.py | 19 ++++---- esphome/core/__init__.py | 35 ++++++++++++++- esphome/cpp_generator.py | 5 +++ tests/unit_tests/build_gen/test_espidf.py | 27 ++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 44 +++++++++++++++++++ tests/unit_tests/test_core.py | 32 ++++++++++++++ 9 files changed, 187 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b65ce23307..5d4e6b8401 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -72,6 +72,13 @@ def has_discovered_components() -> bool: return get_available_components() is not None +def _cmake_quote(value: str) -> str: + """Quote a cmake arg value for a set() line. add_cmake_arg rejects + whitespace, quotes, and '$', so only backslashes need escaping.""" + escaped = value.replace("\\", "\\\\") + return f'"{escaped}"' + + def get_project_cmakelists(minimal: bool = False) -> str: """Generate the top-level CMakeLists.txt for ESP-IDF project. @@ -114,6 +121,15 @@ def get_project_cmakelists(minimal: bool = False) -> str: else "" ) + # CMake variables registered via cg.add_cmake_arg(). Emitted before + # include(project.cmake) so values like EXCLUDE_COMPONENTS are already + # set when project.cmake seeds the component list, and on minimal + # (discovery) writes too so excluded components never register. + cmake_args = "\n".join( + f"set({name} {_cmake_quote(value)})" + for name, value in sorted(CORE.cmake_args.items()) + ) + # 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. @@ -129,18 +145,6 @@ def get_project_cmakelists(minimal: bool = False) -> str: for name in get_managed_component_require_names() ) - # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS - # minus per-component re-includes). project.cmake reads the plain - # EXCLUDE_COMPONENTS variable when seeding the component list, so this - # must be set before project(). Emitted on minimal writes too so the - # discovery reconfigure never registers the excluded components. - excluded_components = get_excluded_builtin_components() - exclude_components_var = ( - f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' - if excluded_components - else "" - ) - # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by @@ -150,13 +154,17 @@ def get_project_cmakelists(minimal: bool = False) -> str: # project_description.json from a build without exclusions may still # list them, and requiring an excluded component pulls it back into # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the + # two can never disagree within one generated file. builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" for name in sorted( - set(get_available_components() or []).difference(excluded_components) + set(get_available_components() or []).difference( + CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";") + ) ) ) ) @@ -184,9 +192,9 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1) set(IDF_TARGET {idf_target}) set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) -include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cmake_args} -{exclude_components_var} +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index b63c4b733d..0a12d344a0 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -63,6 +63,17 @@ def get_ini_content(): # Add extra script for C++ flags CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) + # Add CMake args. A user-supplied value (str or list) is deliberately + # replaced; this option was always overwritten at FINAL priority. + if CORE.cmake_args: + CORE.add_platformio_option( + "board_build.cmake_extra_args", + " ".join( + f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items()) + ), + replace=True, + ) + content = "[platformio]\n" content += f"description = ESPHome {__version__}\n" diff --git a/esphome/codegen.py b/esphome/codegen.py index 2430f17f3a..2aa6a70abd 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cmake_arg, add_cxx_build_flag, add_define, add_global, diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f1f039922a..d6ed6d9399 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -760,9 +760,10 @@ def include_builtin_idf_component(name: str) -> None: def get_excluded_builtin_components() -> list[str]: """Return the sorted built-in IDF components excluded from the build. - Single accessor for both build writers: the PlatformIO path passes it as - ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the - generated CMakeLists. + The set reaches both build writers as the ``EXCLUDE_COMPONENTS`` CMake + arg (registered via ``cg.add_cmake_arg`` at FINAL priority); the native + ESP-IDF writer also reads it directly to filter the built-in component + list. """ return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) @@ -2148,14 +2149,16 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +def register_exclude_components_cmake_arg() -> None: + """Register the current exclusion set as the EXCLUDE_COMPONENTS cmake arg.""" + if excluded := get_excluded_builtin_components(): + cg.add_cmake_arg("EXCLUDE_COMPONENTS", ";".join(excluded)) + + @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if excluded := get_excluded_builtin_components(): - cg.add_platformio_option( - "board_build.cmake_extra_args", - f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", - ) + register_exclude_components_cmake_arg() @coroutine_with_priority(CoroPriority.FINAL) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 534b740a5d..0f1ac9213e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -641,6 +641,8 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A map of CMake args to apply to build systems that use CMake. + self.cmake_args: dict[str, str] = {} # A set of build flags that apply to C++ compiles only (CXXFLAGS / # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C self.cxx_build_flags: set[str] = set() @@ -704,6 +706,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cmake_args = {} self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None @@ -1062,6 +1065,30 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cmake_arg(self, name: str, value: str) -> None: + """Register a CMake variable for CMake-based toolchains. + + The value must not contain whitespace or quotes (the PlatformIO + backend passes all args to CMake as a single space-joined string + of ``-DNAME=VALUE`` pairs) or ``$`` (expanded by CMake on the + ESP-IDF path but interpolated differently or passed through by + PlatformIO). + """ + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise ValueError(f"Invalid CMake arg name: {name!r}") + if re.search(r"[\s\"'$]", value): + raise ValueError( + f"CMake arg {name} value {value!r} must not contain " + "whitespace, quotes, or '$'" + ) + old = self.cmake_args.get(name) + if old is not None and old != value: + _LOGGER.warning( + "CMake arg %s already set to %s; overwriting with %s", name, old, value + ) + self.cmake_args[name] = value + _LOGGER.debug("Adding CMake arg: %s=%s", name, value) + def add_cxx_build_flag(self, build_flag: str) -> str: self.cxx_build_flags.add(build_flag) _LOGGER.debug("Adding C++ build flag: %s", build_flag) @@ -1091,10 +1118,14 @@ class EsphomeCore: _LOGGER.debug("Adding define: %s", define) return define - def add_platformio_option(self, key: str, value: str | list[str]) -> None: + def add_platformio_option( + self, key: str, value: str | list[str], *, replace: bool = False + ) -> None: + """Set a platformio.ini option; list values append to an existing list + unless ``replace`` is True, which overwrites any existing value.""" new_val = value old_val = self.platformio_options.get(key) - if isinstance(old_val, list): + if not replace and isinstance(old_val, list): assert isinstance(value, list) new_val = old_val + value self.platformio_options[key] = new_val diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6bcf4eed77..e6b8c0de42 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,11 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cmake_arg(name: str, value: str) -> None: + """Add a CMake arg for CMake-based toolchains; see ``EsphomeCore.add_cmake_arg``.""" + CORE.add_cmake_arg(name, value) + + def add_cxx_build_flag(build_flag: str) -> None: """Add a global build flag that applies to C++ compiles only. diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index ec01000920..29010bcf0e 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( KEY_PATH, KEY_REF, KEY_REPO, + register_exclude_components_cmake_arg, ) import esphome.config_validation as cv from esphome.const import KEY_CORE @@ -137,6 +138,27 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_cmake_args() -> None: + """Args registered via CORE.add_cmake_arg() are emitted as set() lines, + on minimal writes too.""" + CORE.add_cmake_arg("EXECUTABLE_COMPONENT_NAME", "src") + + content = _render(minimal=True) + + assert 'set(EXECUTABLE_COMPONENT_NAME "src")' in content + + +def test_get_project_cmakelists_escapes_backslashes_in_cmake_args() -> None: + """Backslashes (the only character escaping applies to; the rest are + rejected at registration) are doubled so CMake reads the value back + verbatim.""" + CORE.add_cmake_arg("MY_PATH", r"C:\esp\idf") + + content = _render(minimal=True) + + assert r'set(MY_PATH "C:\\esp\\idf")' in content + + def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale @@ -151,6 +173,7 @@ def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None }, ) CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + register_exclude_components_cmake_arg() content = _render() @@ -169,6 +192,7 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: """The discovery (minimal) write also excludes components so they never register in project_description.json.""" CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + register_exclude_components_cmake_arg() content = _render(minimal=True) @@ -177,6 +201,8 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + register_exclude_components_cmake_arg() + content = _render() assert "EXCLUDE_COMPONENTS" not in content @@ -197,6 +223,7 @@ def test_include_builtin_idf_component_removes_exclusion() -> None: assert get_excluded_builtin_components() == ["unity"] + register_exclude_components_cmake_arg() content = _render() assert 'set(EXCLUDE_COMPONENTS "unity")' in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 3df2fb1036..20acbe302c 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -169,6 +169,7 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(CORE, "platformio_libraries", {}) monkeypatch.setattr(CORE, "build_flags", set()) monkeypatch.setattr(CORE, "build_unflags", set()) + monkeypatch.setattr(CORE, "cmake_args", {}) def test_get_ini_content_pins_cpp_standard( @@ -202,6 +203,49 @@ def test_get_ini_content_no_cpp_standard( assert "-std=" not in content +def test_get_ini_content_emits_cmake_args( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Registered args are space-joined into one option, sorted by name.""" + monkeypatch.setattr( + CORE, + "cmake_args", + {"EXECUTABLE_COMPONENT_NAME": "src", "EXCLUDE_COMPONENTS": "unity"}, + ) + + content = platformio.get_ini_content() + + assert ( + "board_build.cmake_extra_args = " + "-DEXCLUDE_COMPONENTS=unity -DEXECUTABLE_COMPONENT_NAME=src" in content + ) + + +def test_get_ini_content_no_cmake_option_when_no_args(clean_core: None) -> None: + """No board_build.cmake_extra_args line at all when nothing registered + (ESP8266/RP2040/LibreTiny builds must not get a blank option).""" + content = platformio.get_ini_content() + + assert "board_build.cmake_extra_args" not in content + + +def test_get_ini_content_overwrites_list_valued_user_cmake_option( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A user-supplied board_build.cmake_extra_args may be a list; the + registered args must replace it without tripping add_platformio_option's + list-append assert.""" + monkeypatch.setattr( + CORE, "platformio_options", {"board_build.cmake_extra_args": ["-DFOO=1"]} + ) + monkeypatch.setattr(CORE, "cmake_args", {"EXECUTABLE_COMPONENT_NAME": "src"}) + + content = platformio.get_ini_content() + + assert "board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src" in content + assert "-DFOO=1" not in content + + def test_write_cxx_flags_script_emits_registered_flags( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 7f00d00ef7..c373116106 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -990,3 +990,35 @@ class TestEsphomeCore: ) # The unflag is still recorded either way. assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} + + def test_add_cmake_arg(self, target) -> None: + target.add_cmake_arg("EXCLUDE_COMPONENTS", "unity;esp_lcd") + assert target.cmake_args == {"EXCLUDE_COMPONENTS": "unity;esp_lcd"} + + @pytest.mark.parametrize("name", ["", "BAD NAME", 'A"B', "A(B)", "1ABC"]) + def test_add_cmake_arg__rejects_invalid_name(self, target, name: str) -> None: + with pytest.raises(ValueError, match="Invalid CMake arg name"): + target.add_cmake_arg(name, "value") + + @pytest.mark.parametrize("value", ["a b", "a\tb", 'a"b', "a'b", "a${FOO}b"]) + def test_add_cmake_arg__rejects_invalid_value(self, target, value: str) -> None: + """Whitespace and quotes are rejected (the PlatformIO backend passes + args as one space-joined string, which would split such a value), and + so is '$' (expanded differently by CMake and PlatformIO).""" + with pytest.raises(ValueError, match="must not contain"): + target.add_cmake_arg("MY_ARG", value) + + def test_add_cmake_arg__warns_on_overwrite( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Re-registering with a different value is last-writer-wins; warn so + the silently dropped value is diagnosable.""" + target.add_cmake_arg("MY_ARG", "one") + target.add_cmake_arg("MY_ARG", "one") + assert "overwriting" not in caplog.text + + target.add_cmake_arg("MY_ARG", "two") + assert ( + "CMake arg MY_ARG already set to one; overwriting with two" in caplog.text + ) + assert target.cmake_args == {"MY_ARG": "two"} From 6343c11873fb62b1ed83b841f4e9c46e5fc73697 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:56:02 +1200 Subject: [PATCH 183/470] [core] Add type annotations to component Python (5/11) (#18342) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/bedjet/__init__.py | 6 ++++-- esphome/components/bedjet/climate/__init__.py | 3 ++- esphome/components/bedjet/fan/__init__.py | 3 ++- esphome/components/bedjet/sensor/__init__.py | 3 ++- esphome/components/bme680_bsec/__init__.py | 3 ++- esphome/components/bme680_bsec/sensor.py | 6 ++++-- esphome/components/bme680_bsec/text_sensor.py | 6 ++++-- esphome/components/cs5460a/sensor.py | 14 +++++++++++--- esphome/components/esp32_touch/__init__.py | 13 ++++++++----- .../components/esp32_touch/binary_sensor.py | 3 ++- esphome/components/esp8266_pwm/output.py | 14 +++++++++++--- esphome/components/factory_reset/__init__.py | 7 ++++--- .../factory_reset/button/__init__.py | 3 ++- .../factory_reset/switch/__init__.py | 3 ++- esphome/components/hbridge/fan/__init__.py | 12 ++++++++++-- esphome/components/hbridge/light/__init__.py | 3 ++- esphome/components/hbridge/switch/__init__.py | 3 ++- esphome/components/hmc5883l/sensor.py | 15 +++++++++++---- esphome/components/mhz19/sensor.py | 19 ++++++++++++++++--- esphome/components/mpr121/__init__.py | 14 +++++++++----- .../mpr121/binary_sensor/__init__.py | 3 ++- esphome/components/pcf85063/time.py | 19 ++++++++++++++++--- esphome/components/pcf8563/time.py | 19 ++++++++++++++++--- esphome/components/pcm5122/audio_dac.py | 12 +++++++----- esphome/components/pcm5122/switch/__init__.py | 3 ++- esphome/components/pmwcs3/sensor.py | 19 ++++++++++++++++--- esphome/components/qmc5883l/sensor.py | 13 +++++++++---- .../components/remote_transmitter/__init__.py | 15 +++++++++++---- esphome/components/rotary_encoder/sensor.py | 14 +++++++++++--- .../components/rp2040_pio_led_strip/light.py | 8 ++++---- esphome/components/rx8130/time.py | 19 ++++++++++++++++--- esphome/components/servo/__init__.py | 19 ++++++++++++++++--- esphome/components/sx1509/__init__.py | 10 ++++++---- .../sx1509/binary_sensor/__init__.py | 3 ++- esphome/components/sx1509/output/__init__.py | 3 ++- 35 files changed, 246 insertions(+), 86 deletions(-) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index d4bf813846..1b967e665a 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jhansche"] DEPENDENCIES = ["ble_client"] @@ -32,12 +34,12 @@ BEDJET_CLIENT_SCHEMA = cv.Schema( ) -async def register_bedjet_child(var, config): +async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_BEDJET_ID]) cg.add(parent.register_child(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 4de9dcca0b..36650d643c 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate import esphome.config_validation as cv from esphome.const import CONF_HEAT_MODE, CONF_TEMPERATURE_SOURCE +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bedjet/fan/__init__.py b/esphome/components/bedjet/fan/__init__.py index a4a611fefc..f5dfe32f4c 100644 --- a/esphome/components/bedjet/fan/__init__.py +++ b/esphome/components/bedjet/fan/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -16,7 +17,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bedjet/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index fa9ca7953e..595e798e49 100644 --- a/esphome/components/bedjet/sensor/__init__.py +++ b/esphome/components/bedjet/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(BEDJET_CLIENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index e1e01facd0..35df2a7ea3 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -3,6 +3,7 @@ from esphome.components import esp32, i2c from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework +from esphome.types import ConfigType CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] @@ -76,7 +77,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index bdc8d8f2d3..153890b57f 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -29,6 +29,8 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent @@ -110,7 +112,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -120,7 +122,7 @@ async def setup_conf(config, key, hub): ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme680_bsec/text_sensor.py b/esphome/components/bme680_bsec/text_sensor.py index 1fbb9e2aeb..6da1c9d287 100644 --- a/esphome/components/bme680_bsec/text_sensor.py +++ b/esphome/components/bme680_bsec/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, BME680BSECComponent @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 0c6ae0d821..5f14457101 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@balrog-kun"] DEPENDENCIES = ["spi"] @@ -40,7 +43,7 @@ CONF_VOLTAGE_HPF = "voltage_hpf" CONF_PULSE_ENERGY = "pulse_energy" -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: current_gain = abs(config[CONF_CURRENT_GAIN]) * ( 1.0 if config[CONF_PGA_GAIN] == "10X" else 5.0 ) @@ -105,7 +108,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -138,6 +141,11 @@ async def to_code(config): ), synchronous=True, ) -async def restart_action_to_code(config, action_id, template_arg, args): +async def restart_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 10ad339b12..ede6beb9b6 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable, Iterable import logging +from typing import Any import esphome.codegen as cg from esphome.components import esp32 @@ -23,6 +25,7 @@ from esphome.const import ( CONF_VOLTAGE_ATTENUATION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -181,7 +184,7 @@ EFFECTIVE_HIGH_VOLTAGE = { } -def validate_touch_pad(value): +def validate_touch_pad(value: Any) -> int: value = gpio.gpio_pin_number_validator(value) variant = get_esp32_variant() pads = TOUCH_PADS.get(variant) @@ -192,7 +195,7 @@ def validate_touch_pad(value): return pads[value] # Return integer channel ID -def validate_variant_vars(config): +def validate_variant_vars(config: ConfigType) -> ConfigType: variant = get_esp32_variant() invalid_vars = set() if variant == VARIANT_ESP32: @@ -219,8 +222,8 @@ def validate_variant_vars(config): return config -def validate_voltage(values): - def validator(value): +def validate_voltage(values: Iterable[str]) -> Callable[[Any], str]: + def validator(value: Any) -> str: if isinstance(value, float) and value.is_integer(): value = int(value) value = cv.string(value) @@ -300,7 +303,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # New unified touch sensor driver include_builtin_idf_component("esp_driver_touch_sens") diff --git a/esphome/components/esp32_touch/binary_sensor.py b/esphome/components/esp32_touch/binary_sensor.py index 75560d71b1..2489c2abc1 100644 --- a/esphome/components/esp32_touch/binary_sensor.py +++ b/esphome/components/esp32_touch/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType from . import ESP32TouchComponent, esp32_touch_ns, validate_touch_pad @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(ESP32TouchBinarySensor).exten ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_ESP32_TOUCH_ID]) var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index f119a6ba9f..dd151a3e04 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,11 +4,14 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] -def valid_pwm_pin(value): +def valid_pwm_pin(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] cv.one_of(0, 1, 2, 3, 4, 5, 9, 10, 12, 13, 14, 15, 16)(num) return value @@ -35,7 +38,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: require_waveform() var = cg.new_Pvariable(config[CONF_ID]) @@ -59,7 +62,12 @@ async def to_code(config) -> None: ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index d5d5d2ecb5..a9064eb18f 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@anatoly-savchenkov"] @@ -23,7 +24,7 @@ CONF_RESETS_REQUIRED = "resets_required" CONF_ON_INCREMENT = "on_increment" -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_RESETS_REQUIRED in config: return cv.only_on( [ @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): @@ -81,7 +82,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if reset_count := config.get(CONF_RESETS_REQUIRED): var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/factory_reset/button/__init__.py b/esphome/components/factory_reset/button/__init__.py index 61df5f297b..040614c151 100644 --- a/esphome/components/factory_reset/button/__init__.py +++ b/esphome/components/factory_reset/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import factory_reset_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = button.button_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await button.register_button(var, config) diff --git a/esphome/components/factory_reset/switch/__init__.py b/esphome/components/factory_reset/switch/__init__.py index a384a57f80..69a635a917 100644 --- a/esphome/components/factory_reset/switch/__init__.py +++ b/esphome/components/factory_reset/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import factory_reset_ns @@ -17,6 +18,6 @@ CONFIG_SCHEMA = switch.switch_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 8ea8677ba2..2cf1693b47 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_PRESET_MODES, CONF_SPEED_COUNT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import hbridge_ns @@ -54,12 +57,17 @@ CONFIG_SCHEMA = ( maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), synchronous=True, ) -async def fan_hbridge_brake_to_code(config, action_id, template_arg, args): +async def fan_hbridge_brake_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan( config, config[CONF_SPEED_COUNT], diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f9451e2594..f7866cb990 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType from .. import hbridge_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index e26bd6b1d8..294be6ed5f 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_OPTIMISTIC, CONF_PULSE_LENGTH, CONF_WAIT_TIME +from esphome.types import ConfigType from .. import hbridge_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index cf3c594f36..a2e1f8054a 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv @@ -17,6 +20,8 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -59,14 +64,16 @@ HMC5883L_RANGES = { } -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -112,7 +119,7 @@ CONFIG_SCHEMA = ( ) -def auto_data_rate(config): +def auto_data_rate(config: ConfigType) -> MockObj: interval_msec = config[CONF_UPDATE_INTERVAL].total_milliseconds interval_hz = 1000.0 / interval_msec for datarate in sorted(HMC5883LDatarates.keys()): @@ -121,7 +128,7 @@ def auto_data_rate(config): return HMC5883LDatarates[75] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index b7d0ad1998..33cb27080c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -78,7 +81,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -129,7 +132,12 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_no_args_action_to_code(config, action_id, template_arg, args): +async def mhz19_no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -151,7 +159,12 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( RANGE_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_detection_range_set_to_code(config, action_id, template_arg, args): +async def mhz19_detection_range_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) detection_range = config.get(CONF_DETECTION_RANGE) diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index 0bf9377275..da56b4ff4b 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -12,7 +12,9 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType CONF_TOUCH_THRESHOLD = "touch_threshold" CONF_RELEASE_THRESHOLD = "release_threshold" @@ -49,7 +51,7 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: fconf = fv.full_config.get() max_touch_channel = 3 if (binary_sensors := fconf.get(CONF_BINARY_SENSOR)) is not None: @@ -71,7 +73,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_touch_debounce(config[CONF_TOUCH_DEBOUNCE])) cg.add(var.set_release_debounce(config[CONF_RELEASE_DEBOUNCE])) @@ -82,7 +84,7 @@ async def to_code(config): await i2c.register_i2c_device(var, config) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if bool(value[CONF_INPUT]) == bool(value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") return value @@ -105,7 +107,9 @@ MPR121_GPIO_PIN_SCHEMA = pins.gpio_base_schema( ) -def mpr121_pin_final_validate(pin_config, parent_config): +def mpr121_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: if pin_config[CONF_NUMBER] <= parent_config[CONF_MAX_TOUCH_CHANNEL]: raise cv.Invalid( "Pin number must be higher than the max touch channel of the MPR121 component", @@ -115,7 +119,7 @@ def mpr121_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_MPR121, MPR121_GPIO_PIN_SCHEMA, mpr121_pin_final_validate ) -async def mpr121_gpio_pin_to_code(config): +async def mpr121_gpio_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MPR121]) diff --git a/esphome/components/mpr121/binary_sensor/__init__.py b/esphome/components/mpr121/binary_sensor/__init__.py index 1252a65a84..565789cdc3 100644 --- a/esphome/components/mpr121/binary_sensor/__init__.py +++ b/esphome/components/mpr121/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_CHANNEL +from esphome.types import ConfigType from .. import ( CONF_MPR121_ID, @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(MPR121BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_MPR121_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 8e19178cc9..771461905e 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@brogon"] DEPENDENCIES = ["i2c"] @@ -31,7 +34,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf85063_write_time_to_code(config, action_id, template_arg, args): +async def pcf85063_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -47,13 +55,18 @@ async def pcf85063_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def pcf85063_read_time_to_code(config, action_id, template_arg, args): +async def pcf85063_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 1502158c29..8a0b871be9 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -34,7 +37,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf8563_write_time_to_code(config, action_id, template_arg, args): +async def pcf8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -50,13 +58,18 @@ async def pcf8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def pcf8563_read_time_to_code(config, action_id, template_arg, args): +async def pcf8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index c18fb3993e..5091efabea 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -13,6 +13,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] @@ -50,7 +52,7 @@ PCM5122_CHANNEL_MIX_ENUM = { _validate_bits = cv.float_with_unit("bits", "bit") -def _validate_volume_range(config): +def _validate_volume_range(config: ConfigType) -> ConfigType: if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") return config @@ -90,7 +92,7 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_pin_mode(value): +def _validate_pin_mode(value: ConfigType) -> ConfigType: 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]: @@ -98,7 +100,7 @@ def _validate_pin_mode(value): return value -def _validate_pin(value): +def _validate_pin(value: ConfigType) -> ConfigType: 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 @@ -120,7 +122,7 @@ PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) -async def pcm5122_pin_to_code(config): +async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_PCM5122]) @@ -130,7 +132,7 @@ async def pcm5122_pin_to_code(config): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py index 10519da895..829adeccb7 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_parented(var, config[CONF_PCM5122]) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index c0bc54c5ba..ae22b3e0d6 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -10,6 +10,9 @@ from esphome.const import ( ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@SeByDocKy"] DEPENDENCIES = ["i2c"] @@ -72,7 +75,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -114,7 +117,12 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): +async def pmwcs3_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, parent) @@ -134,7 +142,12 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( PMWCS3_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): +async def pmwcs3newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index fe34381ad8..e0186be163 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -60,7 +63,7 @@ QMC5883LOversamplings = { } -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if ( config[CONF_UPDATE_INTERVAL].total_milliseconds < 15 and CONF_DRDY_PIN not in config @@ -72,14 +75,16 @@ def validate_config(config): return config -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -137,7 +142,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 521c3daf87..a97b925e06 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -18,7 +18,9 @@ from esphome.const import ( CONF_VALUE, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -94,7 +96,7 @@ CONFIG_SCHEMA = ( ) -def _validate_non_blocking(config): +def _validate_non_blocking(config: ConfigType) -> None: if ( CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT @@ -125,7 +127,12 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( DIGITAL_WRITE_ACTION_SCHEMA, synchronous=True, ) -async def digital_write_action_to_code(config, action_id, template_arg, args): +async def digital_write_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.bool_) @@ -133,7 +140,7 @@ async def digital_write_action_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 0e5a03523d..72722ec4b1 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") @@ -44,7 +47,7 @@ RotaryEncoderSetValueAction = rotary_encoder_ns.class_( ) -def validate_min_max_value(config): +def validate_min_max_value(config: ConfigType) -> ConfigType: if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: min_val = config[CONF_MIN_VALUE] max_val = config[CONF_MAX_VALUE] @@ -92,7 +95,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -126,7 +129,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): +async def sensor_template_publish_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 9f7479edd0..5b7259f9e5 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -18,7 +18,7 @@ from esphome.types import ConfigType from esphome.util import _LOGGER -def get_nops(timing): +def get_nops(timing: float) -> list[float | str]: """ Calculate the number of NOP instructions required to wait for a given amount of time. """ @@ -39,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, t0h, t0l, t1h, t1l): +def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: """ Generate assembly code with the given timing values. """ @@ -125,7 +125,7 @@ writezero: return assembly_template + const_csdk_code -def time_to_cycles(time_us): +def time_to_cycles(time_us: float) -> int: cycles_per_us = 57.5 return round(float(time_us) * cycles_per_us) @@ -172,7 +172,7 @@ CONF_BIT1_HIGH = "bit1_high" CONF_BIT1_LOW = "bit1_low" -def _validate_timing(value): +def _validate_timing(value: str) -> float: # if doesn't end with us, raise error if not value.endswith("us"): raise cv.Invalid("Timing must be in microseconds (us)") diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 4f6310358c..40d10e9f6b 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def rx8130_write_time_to_code(config, action_id, template_arg, args): +async def rx8130_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def rx8130_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def rx8130_read_time_to_code(config, action_id, template_arg, args): +async def rx8130_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index c2eaefe455..666c7dbcdd 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_RESTORE, CONF_TRANSITION_LENGTH, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) @@ -39,7 +42,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -64,7 +67,12 @@ async def to_code(config): ), synchronous=True, ) -async def servo_write_to_code(config, action_id, template_arg, args): +async def servo_write_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) @@ -82,6 +90,11 @@ async def servo_write_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def servo_detach_to_code(config, action_id, template_arg, args): +async def servo_detach_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index b61b92fd1e..c1e4e11d54 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, CONF_TRIGGER_ID, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_KEYPAD = "keypad" CONF_KEYS = "keys" @@ -40,7 +42,7 @@ SX1509KeyTrigger = sx1509_ns.class_( ) -def check_keys(config): +def check_keys(config: ConfigType) -> ConfigType: if ( CONF_KEYS in config and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] @@ -82,7 +84,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -104,7 +106,7 @@ async def to_code(config): await automation.build_automation(trigger, [(cg.uint8, "x")], tconf) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -142,7 +144,7 @@ SX1509_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_SX1509, SX1509_PIN_SCHEMA) -async def sx1509_pin_to_code(config): +async def sx1509_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_SX1509]) cg.add(var.set_parent(parent)) diff --git a/esphome/components/sx1509/binary_sensor/__init__.py b/esphome/components/sx1509/binary_sensor/__init__.py index 0ceca77a5d..154a841348 100644 --- a/esphome/components/sx1509/binary_sensor/__init__.py +++ b/esphome/components/sx1509/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ROW +from esphome.types import ConfigType from .. import CONF_SX1509_ID, SX1509Component, sx1509_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(SX1509BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_SX1509_ID]) cg.add(var.set_row_col(config[CONF_ROW], config[CONF_COL])) diff --git a/esphome/components/sx1509/output/__init__.py b/esphome/components/sx1509/output/__init__.py index 9e2db7bb10..aed5ab7dd4 100644 --- a/esphome/components/sx1509/output/__init__.py +++ b/esphome/components/sx1509/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_SX1509_ID, SX1509Component, sx1509_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SX1509_ID]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From c006e9804a2e88d5852ca6761800c01c0cde6fa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:56:48 +1200 Subject: [PATCH 184/470] [core] Add type annotations to component Python (9/11) (#18346) --- esphome/components/as3935/__init__.py | 4 +++- esphome/components/as3935/binary_sensor.py | 3 ++- esphome/components/as3935/sensor.py | 3 ++- esphome/components/bthome_mithermometer/__init__.py | 8 ++++++-- esphome/components/bthome_mithermometer/sensor.py | 3 ++- esphome/components/color/__init__.py | 11 +++++++---- esphome/components/ds248x/__init__.py | 7 ++++--- esphome/components/ds248x/one_wire.py | 5 +++-- esphome/components/emontx/__init__.py | 10 +++++++--- esphome/components/gdk101/__init__.py | 3 ++- esphome/components/gdk101/binary_sensor.py | 3 ++- esphome/components/gdk101/sensor.py | 3 ++- esphome/components/gdk101/text_sensor.py | 3 ++- esphome/components/hc8/sensor.py | 12 ++++++++++-- esphome/components/lcd_base/__init__.py | 10 +++++++--- esphome/components/libretiny_pwm/output.py | 12 ++++++++++-- esphome/components/lightwaverf/__init__.py | 12 ++++++++++-- esphome/components/max17043/sensor.py | 12 ++++++++++-- esphome/components/nau7802/sensor.py | 12 ++++++++++-- esphome/components/ntc/sensor.py | 12 +++++++----- esphome/components/openthread_info/sensor.py | 5 +++-- esphome/components/openthread_info/text_sensor.py | 5 +++-- esphome/components/pmsx003/sensor.py | 12 ++++++++---- esphome/components/pzemac/sensor.py | 11 +++++++++-- esphome/components/pzemdc/sensor.py | 11 +++++++++-- esphome/components/remote_receiver/__init__.py | 9 ++++++--- esphome/components/remote_receiver/binary_sensor.py | 3 ++- esphome/components/scd30/sensor.py | 12 +++++++++--- esphome/components/senseair/sensor.py | 12 ++++++++++-- esphome/components/sml/__init__.py | 6 ++++-- esphome/components/sml/sensor/__init__.py | 3 ++- esphome/components/sml/text_sensor/__init__.py | 3 ++- esphome/components/sn74hc595/__init__.py | 12 ++++++++---- esphome/components/spa06_base/__init__.py | 12 +++++++----- esphome/components/sy6970/__init__.py | 3 ++- esphome/components/sy6970/binary_sensor/__init__.py | 3 ++- esphome/components/sy6970/sensor/__init__.py | 3 ++- esphome/components/sy6970/text_sensor/__init__.py | 3 ++- esphome/components/tm1638/binary_sensor/__init__.py | 3 ++- esphome/components/tm1638/display.py | 3 ++- esphome/components/tm1638/output/__init__.py | 3 ++- esphome/components/tm1638/switch/__init__.py | 3 ++- esphome/components/uponor_smatrix/__init__.py | 6 ++++-- .../components/uponor_smatrix/climate/__init__.py | 3 ++- .../components/uponor_smatrix/sensor/__init__.py | 3 ++- esphome/components/vl53l0x/sensor.py | 10 +++++++--- esphome/components/weikai/__init__.py | 12 +++++++----- esphome/components/zephyr_ble_server/__init__.py | 13 ++++++++++--- 48 files changed, 238 insertions(+), 97 deletions(-) diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index 70015c53b9..bd02d22d1b 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -14,6 +14,8 @@ from esphome.const import ( CONF_TUNE_ANTENNA, CONF_WATCHDOG_THRESHOLD, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -42,7 +44,7 @@ AS3935_SCHEMA = cv.Schema( ) -async def setup_as3935(var, config): +async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) diff --git a/esphome/components/as3935/binary_sensor.py b/esphome/components/as3935/binary_sensor.py index 10004e69dc..929b653294 100644 --- a/esphome/components/as3935/binary_sensor.py +++ b/esphome/components/as3935/binary_sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -13,7 +14,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) var = await binary_sensor.new_binary_sensor(config) cg.add(hub.set_thunder_alert_binary_sensor(var)) diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index 9b43155563..b727b8fdb9 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_KILOMETER, ) +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -31,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) if distance_config := config.get(CONF_DISTANCE): diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 4be7ca8268..ed0cbaa9e1 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -3,6 +3,8 @@ from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] @@ -14,7 +16,9 @@ BTHomeMiThermometer = bthome_mithermometer_ns.class_( ) -def bthome_mithermometer_base_schema(extra_schema=None): +def bthome_mithermometer_base_schema( + extra_schema: cv.Schema | dict | None = None, +) -> cv.All: if extra_schema is None: extra_schema = {} return cv.All( @@ -32,7 +36,7 @@ def bthome_mithermometer_base_schema(extra_schema=None): ) -async def setup_bthome_mithermometer(var, config): +async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 02551391ad..f559d0aa9b 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -20,6 +20,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer @@ -67,7 +68,7 @@ CONFIG_SCHEMA = bthome_mithermometer_base_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index c39c5924af..70240eff07 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -1,5 +1,8 @@ +from typing import Any + from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_BLUE, CONF_GREEN, CONF_ID, CONF_RED, CONF_WHITE +from esphome.types import ConfigType ColorStruct = cg.esphome_ns.struct("Color") @@ -14,7 +17,7 @@ CONF_WHITE_INT = "white_int" CONF_HEX = "hex" -def hex_color(value): +def hex_color(value: Any) -> tuple[int, int, int]: if isinstance(value, int): value = str(value) if not isinstance(value, str): @@ -39,7 +42,7 @@ components = { } -def validate_color(config): +def validate_color(config: ConfigType) -> ConfigType: has_components = set(config) & components has_hex = CONF_HEX in config if has_hex and has_components: @@ -68,7 +71,7 @@ CONFIG_SCHEMA = cv.All( ) -def from_rgbw(config): +def from_rgbw(config: ConfigType) -> tuple[int, int, int, int]: r = 0 if CONF_RED in config: r = int(config[CONF_RED] * 255) @@ -96,7 +99,7 @@ def from_rgbw(config): return (r, g, b, w) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_HEX in config: r, g, b = config[CONF_HEX] w = 0 diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 5a26ceab50..a2e2a87ed0 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE +from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] MULTI_CONF = True @@ -35,7 +36,7 @@ ds248x_ns = cg.esphome_ns.namespace("ds248x") DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) -def _component_schema(*extras): +def _component_schema(*extras: dict) -> cv.Schema: schema = cv.Schema( { cv.GenerateID(): cv.declare_id(DS248xComponent), @@ -79,11 +80,11 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def get_channel_count(config): +def get_channel_count(config: ConfigType) -> int: return CHANNEL_COUNTS[config[CONF_TYPE]] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index 19861eae36..b028958132 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -12,6 +12,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: """Validate that the channel is within the parent's channel count.""" fconf = fv.full_config.get() path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1] @@ -47,7 +48,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3f83578926..7dde794f0b 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -143,8 +144,11 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def emontx_send_command_action_to_code( - config: ConfigType, action_id, template_arg, args -) -> None: + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index 878f27bc44..f98af3f863 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/gdk101/binary_sensor.py b/esphome/components/gdk101/binary_sensor.py index a80487977f..14f5fa0e1c 100644 --- a/esphome/components/gdk101/binary_sensor.py +++ b/esphome/components/gdk101/binary_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ICON_VIBRATE, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await binary_sensor.new_binary_sensor(config[CONF_VIBRATIONS]) cg.add(hub.set_vibration_binary_sensor(var)) diff --git a/esphome/components/gdk101/sensor.py b/esphome/components/gdk101/sensor.py index 6cf89e0fd4..4ed081a7be 100644 --- a/esphome/components/gdk101/sensor.py +++ b/esphome/components/gdk101/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_MICROSILVERTS_PER_HOUR, UNIT_SECOND, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) if radiation_dose_per_1m := config.get(CONF_RADIATION_DOSE_PER_1M): diff --git a/esphome/components/gdk101/text_sensor.py b/esphome/components/gdk101/text_sensor.py index 703e68493a..bdef2466df 100644 --- a/esphome/components/gdk101/text_sensor.py +++ b/esphome/components/gdk101/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await text_sensor.new_text_sensor(config[CONF_VERSION]) cg.add(hub.set_fw_version_text_sensor(var)) diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 29b428e310..616162eb40 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,6 +12,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -47,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -73,7 +76,12 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def hc8_calibration_to_code(config, action_id, template_arg, args): +async def hc8_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) diff --git a/esphome/components/lcd_base/__init__.py b/esphome/components/lcd_base/__init__.py index bf1072ce66..08ec395720 100644 --- a/esphome/components/lcd_base/__init__.py +++ b/esphome/components/lcd_base/__init__.py @@ -1,7 +1,11 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_DIMENSIONS, CONF_POSITION +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_USER_CHARACTERS = "user_characters" @@ -9,7 +13,7 @@ lcd_base_ns = cg.esphome_ns.namespace("lcd_base") LCDDisplay = lcd_base_ns.class_("LCDDisplay", cg.PollingComponent) -def validate_lcd_dimensions(value): +def validate_lcd_dimensions(value: Any) -> list[int]: value = cv.dimensions(value) if value[0] > 0x40: raise cv.Invalid("LCD displays can't have more than 64 columns") @@ -18,7 +22,7 @@ def validate_lcd_dimensions(value): return value -def validate_user_characters(value): +def validate_user_characters(value: list[ConfigType]) -> list[ConfigType]: positions = set() for conf in value: if conf[CONF_POSITION] in positions: @@ -51,7 +55,7 @@ LCD_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def setup_lcd_display(var, config): +async def setup_lcd_display(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) if CONF_USER_CHARACTERS in config: diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 6f71530aaf..716ccfad2b 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] @@ -21,7 +24,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -40,7 +43,12 @@ async def to_code(config): ), synchronous=True, ) -async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): +async def libretiny_pwm_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 76eabc2b71..0f42083cb5 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_helpers import gpio_pin_expression +from esphome.types import ConfigType CODEOWNERS = ["@max246"] @@ -57,7 +60,12 @@ LIGHTWAVE_SEND_SCHEMA = cv.Any( LIGHTWAVE_SEND_SCHEMA, synchronous=True, ) -async def send_raw_to_code(config, action_id, template_arg, args): +async def send_raw_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -71,7 +79,7 @@ async def send_raw_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index ebb045dfce..67fb8aa5b7 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +53,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -74,6 +77,11 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True ) -async def max17043_sleep_mode_to_code(config, action_id, template_arg, args): +async def max17043_sleep_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 9798c1c297..415ae09daf 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, ICON_SCALE, STATE_CLASS_MEASUREMENT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@cujomalainey"] DEPENDENCIES = ["i2c"] @@ -93,7 +96,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -131,7 +134,12 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -async def nau7802_calibrate_to_code(config, action_id, template_arg, args): +async def nau7802_calibrate_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index dd7d1bd35d..6c2cb69990 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -1,4 +1,5 @@ from math import log +from typing import Any import esphome.codegen as cg from esphome.components import sensor @@ -15,6 +16,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType ntc_ns = cg.esphome_ns.namespace("ntc") NTC = ntc_ns.class_("NTC", cg.Component, sensor.Sensor) @@ -25,7 +27,7 @@ CONF_C = "c" ZERO_POINT = 273.15 -def validate_calibration_parameter(value): +def validate_calibration_parameter(value: Any) -> ConfigType: if isinstance(value, dict): return cv.Schema( { @@ -48,7 +50,7 @@ def validate_calibration_parameter(value): ) -def calc_steinhart_hart(value): +def calc_steinhart_hart(value: list[ConfigType]) -> tuple[float, float, float]: r1 = value[0][CONF_VALUE] r2 = value[1][CONF_VALUE] r3 = value[2][CONF_VALUE] @@ -73,7 +75,7 @@ def calc_steinhart_hart(value): return a, b, c -def calc_b(value): +def calc_b(value: ConfigType) -> tuple[float, float, float]: beta = value[CONF_B_CONSTANT] t0 = value[CONF_REFERENCE_TEMPERATURE] + ZERO_POINT r0 = value[CONF_REFERENCE_RESISTANCE] @@ -85,7 +87,7 @@ def calc_b(value): return a, b, c -def process_calibration(value): +def process_calibration(value: Any) -> ConfigType: if isinstance(value, dict): value = cv.Schema( { @@ -132,7 +134,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/openthread_info/sensor.py b/esphome/components/openthread_info/sensor.py index 4d5b3d54f4..e77b84e17c 100644 --- a/esphome/components/openthread_info/sensor.py +++ b/esphome/components/openthread_info/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, UNIT_EMPTY, ) +from esphome.types import ConfigType CONF_PARENT_AVERAGE_RSSI = "parent_average_rssi" CONF_PARENT_LAST_RSSI = "parent_last_rssi" @@ -166,13 +167,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config: dict, key: str): +async def setup_conf(config: dict, key: str) -> None: if conf := config.get(key): var = await sensor.new_sensor(conf) await cg.register_component(var, conf) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_PARENT_AVERAGE_RSSI) await setup_conf(config, CONF_PARENT_LAST_RSSI) await setup_conf(config, CONF_PARENT_LINK_QUALITY_IN) diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index b672831bf0..da789ae706 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -8,6 +8,7 @@ from esphome.components.openthread.const import ( ) import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_IP_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType CONF_ROLE = "role" CONF_RLOC16 = "rloc16" @@ -86,13 +87,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config: dict, key: str): +async def setup_conf(config: dict, key: str) -> None: if conf := config.get(key): var = await text_sensor.new_text_sensor(conf) await cg.register_component(var, conf) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_IP_ADDRESS) await setup_conf(config, CONF_ROLE) await setup_conf(config, CONF_RLOC16) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 0a11120bf0..fe784c5ffe 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv @@ -32,6 +34,8 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.core import TimePeriodMilliseconds +from esphome.types import ConfigType CODEOWNERS = ["@ximex"] DEPENDENCIES = ["uart"] @@ -167,14 +171,14 @@ SENSORS_TO_TYPE = { } -def validate_pmsx003_sensors(value): +def validate_pmsx003_sensors(value: ConfigType) -> ConfigType: for key, types in SENSORS_TO_TYPE.items(): if key in value and value[CONF_TYPE] not in types: raise cv.Invalid(f"{value[CONF_TYPE]} does not have {key} sensor!") return value -def validate_update_interval(value): +def validate_update_interval(value: Any) -> TimePeriodMilliseconds: value = cv.positive_time_period_milliseconds(value) if value == cv.time_period("0s"): return value @@ -295,7 +299,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: require_tx = config[CONF_UPDATE_INTERVAL] > cv.time_period("0s") schema = uart.final_validate_device_schema( "pmsx003", baud_rate=9600, require_rx=True, require_tx=require_tx @@ -306,7 +310,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 5bb734cb2d..f093262e18 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,6 +26,8 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -93,7 +95,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -105,7 +112,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b2c7c3a29d..b9f7246b72 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,6 +20,8 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -75,7 +77,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -87,7 +94,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index ad9c4b5a18..6e8c73d331 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base @@ -21,6 +23,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType CONF_FILTER_SYMBOLS = "filter_symbols" CONF_RECEIVE_SYMBOLS = "receive_symbols" @@ -62,7 +65,7 @@ RemoteReceiverComponent = remote_receiver_ns.class_( ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in esp32_rmt.VARIANTS_NO_RMT: @@ -78,7 +81,7 @@ def validate_config(config): return config -def validate_tolerance(value): +def validate_tolerance(value: Any) -> ConfigType: if isinstance(value, dict): return TOLERANCE_SCHEMA(value) @@ -196,7 +199,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) diff --git a/esphome/components/remote_receiver/binary_sensor.py b/esphome/components/remote_receiver/binary_sensor.py index fe3e2af950..d4009f396b 100644 --- a/esphome/components/remote_receiver/binary_sensor.py +++ b/esphome/components/remote_receiver/binary_sensor.py @@ -1,4 +1,5 @@ from esphome.components import binary_sensor, remote_base +from esphome.types import ConfigType from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import @@ -7,6 +8,6 @@ DEPENDENCIES = ["remote_receiver"] CONFIG_SCHEMA = remote_base.validate_binary_sensor -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await remote_base.build_binary_sensor(config) await binary_sensor.register_binary_sensor(var, config) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index f60e913a0c..37789100f7 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -22,6 +22,9 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -82,7 +85,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -131,8 +134,11 @@ async def to_code(config): synchronous=True, ) async def scd30_force_recalibration_with_reference_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 277648137a..82368a60d0 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -11,6 +11,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -62,7 +65,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -109,6 +112,11 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def senseair_action_to_code(config, action_id, template_arg, args): +async def senseair_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index d25e883fa1..07ca5bf444 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -1,10 +1,12 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -46,14 +48,14 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -def obis_code(value): +def obis_code(value: Any) -> str: value = cv.string(value) match = re.match(r"^\d{1,3}-\d{1,3}:\d{1,3}\.\d{1,3}\.\d{1,3}$", value) if match is None: diff --git a/esphome/components/sml/sensor/__init__.py b/esphome/components/sml/sensor/__init__.py index e6d7180f17..64ac9773c6 100644 --- a/esphome/components/sml/sensor/__init__.py +++ b/esphome/components/sml/sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_OBIS_CODE, CONF_SERVER_ID, CONF_SML_ID, Sml, obis_code, sml_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_SERVER_ID], config[CONF_OBIS_CODE] ) diff --git a/esphome/components/sml/text_sensor/__init__.py b/esphome/components/sml/text_sensor/__init__.py index 5a5ab658c4..feff4ef256 100644 --- a/esphome/components/sml/text_sensor/__init__.py +++ b/esphome/components/sml/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_FORMAT +from esphome.types import ConfigType from .. import CONF_OBIS_CODE, CONF_SERVER_ID, CONF_SML_ID, Sml, obis_code, sml_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor( config, config[CONF_SERVER_ID], diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 26e5c03802..367b65176b 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_OUTPUT, CONF_TYPE, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -65,7 +67,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if config[CONF_TYPE] == TYPE_GPIO: @@ -84,7 +86,7 @@ async def to_code(config): cg.add(var.set_sr_count(config[CONF_SR_COUNT])) -def _validate_output_mode(value): +def _validate_output_mode(value: ConfigType) -> ConfigType: if value.get(CONF_OUTPUT) is not True: raise cv.Invalid("Only output mode is supported") return value @@ -103,7 +105,9 @@ SN74HC595_PIN_SCHEMA = pins.gpio_base_schema( ) -def sn74hc595_pin_final_validate(pin_config, parent_config): +def sn74hc595_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: max_pins = parent_config[CONF_SR_COUNT] * 8 if pin_config[CONF_NUMBER] >= max_pins: raise cv.Invalid(f"Pin number must be less than {max_pins}") @@ -112,7 +116,7 @@ def sn74hc595_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_SN74HC595, SN74HC595_PIN_SCHEMA, sn74hc595_pin_final_validate ) -async def sn74hc595_pin_to_code(config): +async def sn74hc595_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SN74HC595]) diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index 97d09aad81..c995c2c087 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PASCAL, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@danielkent-net"] @@ -55,7 +57,7 @@ OVERSAMPLING_OPTIONS = { SPA06Component = spa06_ns.class_("SPA06Component", cg.PollingComponent) -def spa_oversample_time(oversample): +def spa_oversample_time(oversample: str) -> float: # Pressure oversampling conversion times are listed on datasheet Pg. 26 # Datasheet does not have a table for temperature oversampling; # assumption is that it is the same as pressure @@ -72,7 +74,7 @@ def spa_oversample_time(oversample): return OVERSAMPLING_CONVERSION_TIMES[oversample] -def spa_sample_rate(rate): +def spa_sample_rate(rate: str) -> float: SAMPLE_RATE_OPTIONS_HZ = { "1": 1.0, "2": 2.0, @@ -94,7 +96,7 @@ def spa_sample_rate(rate): return SAMPLE_RATE_OPTIONS_HZ[rate] -def compute_measurement_conversion_time(config): +def compute_measurement_conversion_time(config: ConfigType) -> int: # - adds up sensor conversion time based on temperature and pressure oversampling rates given in datasheet # - returns a rounded up time in ms @@ -115,7 +117,7 @@ def compute_measurement_conversion_time(config): return math.ceil(1.05 * (pressure_conversion_time + temperature_conversion_time)) -def measurement_timing_check(config): +def measurement_timing_check(config: ConfigType) -> ConfigType: temp_time = 0.0 if temperature_config := config.get(CONF_TEMPERATURE): @@ -176,7 +178,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( CONFIG_SCHEMA_BASE.add_extra(measurement_timing_check) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if temperature_config := config.get(CONF_TEMPERATURE): diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 2390d046e4..cb9d64aee7 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] DEPENDENCIES = ["i2c"] @@ -48,7 +49,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_ENABLE_STATUS_LED], diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index 132b282051..c95850aadc 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_POWER +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if vbus_connected_config := config.get(CONF_VBUS_CONNECTED): diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index e6ee9d1337..8f8090b6ee 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIAMP, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -71,7 +72,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if vbus_voltage_config := config.get(CONF_VBUS_VOLTAGE): diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index 2a4eb90811..03a55393b9 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if bus_status_config := config.get(CONF_BUS_STATUS): diff --git a/esphome/components/tm1638/binary_sensor/__init__.py b/esphome/components/tm1638/binary_sensor/__init__.py index de6ea35e54..4f89b7bf5e 100644 --- a/esphome/components/tm1638/binary_sensor/__init__.py +++ b/esphome/components/tm1638/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_KEY +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -15,7 +16,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) cg.add(var.set_keycode(config[CONF_KEY])) hub = await cg.get_variable(config[CONF_TM1638_ID]) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index 14b70be94d..d6491129c6 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_LAMBDA, CONF_STB_PIN, ) +from esphome.types import ConfigType CODEOWNERS = ["@skykingjwc"] @@ -31,7 +32,7 @@ CONFIG_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output/__init__.py index b16b08d504..961abfee47 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_LED +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -17,7 +18,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index 90ff87938c..f42b835e03 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_LED +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 9588b0df7f..093408e868 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@kroimon"] @@ -61,7 +63,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -74,7 +76,7 @@ async def to_code(config): cg.add(var.set_time_device_address(time_device_address)) -async def register_uponor_smatrix_device(var, config): +async def register_uponor_smatrix_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_UPONOR_SMATRIX_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/uponor_smatrix/climate/__init__.py b/esphome/components/uponor_smatrix/climate/__init__.py index 47495fde9a..e80f59df24 100644 --- a/esphome/components/uponor_smatrix/climate/__init__.py +++ b/esphome/components/uponor_smatrix/climate/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate +from esphome.types import ConfigType from .. import ( UPONOR_SMATRIX_DEVICE_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = climate.climate_schema(UponorSmatrixClimate).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) diff --git a/esphome/components/uponor_smatrix/sensor/__init__.py b/esphome/components/uponor_smatrix/sensor/__init__.py index f2b34538ba..52e755f005 100644 --- a/esphome/components/uponor_smatrix/sensor/__init__.py +++ b/esphome/components/uponor_smatrix/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import ( UPONOR_SMATRIX_DEVICE_SCHEMA, @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.COMPONENT_SCHEMA.extend( ).extend(UPONOR_SMATRIX_DEVICE_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) diff --git a/esphome/components/vl53l0x/sensor.py b/esphome/components/vl53l0x/sensor.py index 583d6ccca9..3029e0f77b 100644 --- a/esphome/components/vl53l0x/sensor.py +++ b/esphome/components/vl53l0x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c, sensor @@ -10,6 +12,8 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.core import TimePeriodMicroseconds +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -23,7 +27,7 @@ CONF_LONG_RANGE = "long_range" CONF_TIMING_BUDGET = "timing_budget" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if obj[CONF_ADDRESS] != 0x29 and CONF_ENABLE_PIN not in obj: msg = "Address other then 0x29 requires enable_pin definition to allow sensor\r" msg += "re-addressing. Also if you have more then one VL53 device on the same\r" @@ -32,7 +36,7 @@ def check_keys(obj): return obj -def check_timeout(value): +def check_timeout(value: Any) -> TimePeriodMicroseconds: value = cv.positive_time_period_microseconds(value) if value.total_seconds > 60: raise cv.Invalid("Maximum timeout can not be greater then 60 seconds") @@ -70,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_signal_rate_limit(config[CONF_SIGNAL_RATE_LIMIT])) diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index bc80f167ef..8f0cf4ba33 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] AUTO_LOAD = ["uart"] @@ -26,7 +28,7 @@ WeikaiComponent = weikai_ns.class_("WeikaiComponent", cg.Component) WeikaiChannel = weikai_ns.class_("WeikaiChannel", uart.UARTComponent) -def check_channel_max(value, max): +def check_channel_max(value: ConfigType, max: int) -> ConfigType: channel_uniq = [] channel_dup = [] for x in value[CONF_UART]: @@ -41,11 +43,11 @@ def check_channel_max(value, max): return value -def check_channel_max_4(value): +def check_channel_max_4(value: ConfigType) -> ConfigType: return check_channel_max(value, 4) -def check_channel_max_2(value): +def check_channel_max_2(value: ConfigType) -> ConfigType: return check_channel_max(value, 2) @@ -70,7 +72,7 @@ WKBASE_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def register_weikai(var, config): +async def register_weikai(var: MockObj, config: ConfigType) -> None: """Register an weikai device with the given config.""" cg.add(var.set_crystal(config[CONF_CRYSTAL])) cg.add(var.set_test_mode(config[CONF_TEST_MODE])) @@ -85,7 +87,7 @@ async def register_weikai(var, config): cg.add(chan.set_parity(uart_elem[CONF_PARITY])) -def validate_pin_mode(value): +def validate_pin_mode(value: ConfigType) -> ConfigType: """Checks input/output mode inconsistency""" if not (value[CONF_MODE][CONF_INPUT] or value[CONF_MODE][CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 658137d1a2..463b9c0887 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -3,7 +3,9 @@ import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -32,7 +34,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT", True) zephyr_add_prj_conf("BT_PERIPHERAL", True) @@ -65,7 +67,12 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, synchronous=True, ) -async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): +async def numeric_comparison_reply_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) From 12da2140cf93273875315823ba041eb4b5841ade Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:57:14 +1200 Subject: [PATCH 185/470] [core] Add type annotations to component Python (11/11) (#18348) --- esphome/components/animation/image.py | 9 ++++++++- esphome/components/apds9960/__init__.py | 3 ++- esphome/components/apds9960/binary_sensor.py | 3 ++- esphome/components/apds9960/sensor.py | 3 ++- esphome/components/emc2101/__init__.py | 3 ++- esphome/components/emc2101/output/__init__.py | 3 ++- esphome/components/emc2101/sensor/__init__.py | 3 ++- esphome/components/graph/__init__.py | 9 ++++++--- esphome/components/pylontech/__init__.py | 3 ++- esphome/components/pylontech/sensor/__init__.py | 3 ++- esphome/components/pylontech/text_sensor/__init__.py | 3 ++- esphome/components/rd03d/__init__.py | 3 ++- esphome/components/rd03d/binary_sensor.py | 3 ++- esphome/components/rd03d/sensor.py | 3 ++- esphome/components/sun_gtil2/__init__.py | 3 ++- esphome/components/sun_gtil2/sensor.py | 3 ++- esphome/components/sun_gtil2/text_sensor.py | 3 ++- esphome/components/teleinfo/__init__.py | 3 ++- esphome/components/teleinfo/sensor/__init__.py | 3 ++- esphome/components/teleinfo/text_sensor/__init__.py | 3 ++- esphome/components/ufm01/__init__.py | 3 ++- esphome/components/ufm01/binary_sensor.py | 3 ++- esphome/components/ufm01/sensor.py | 3 ++- esphome/components/xl9535/__init__.py | 10 ++++++---- 24 files changed, 62 insertions(+), 29 deletions(-) diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 73d428bd20..0265a350f7 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -6,6 +6,8 @@ from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] @@ -79,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema( @automation.register_action( "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True ) -async def animation_action_to_code(config, action_id, template_arg, args): +async def animation_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 99e37d3764..7ac1e5eb32 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -57,7 +58,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/apds9960/binary_sensor.py b/esphome/components/apds9960/binary_sensor.py index 48e923ab2b..342f688249 100644 --- a/esphome/components/apds9960/binary_sensor.py +++ b/esphome/components/apds9960/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await binary_sensor.new_binary_sensor(config) func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor") diff --git a/esphome/components/apds9960/sensor.py b/esphome/components/apds9960/sensor.py index 468eb0995f..a75fb79d1b 100644 --- a/esphome/components/apds9960/sensor.py +++ b/esphome/components/apds9960/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await sensor.new_sensor(config) func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor") diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 323195e99a..639847345f 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -68,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/emc2101/output/__init__.py b/esphome/components/emc2101/output/__init__.py index 586f0800a6..a8820345e2 100644 --- a/esphome/components/emc2101/output/__init__.py +++ b/esphome/components/emc2101/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await output.register_output(var, config) diff --git a/esphome/components/emc2101/sensor/__init__.py b/esphome/components/emc2101/sensor/__init__.py index b6a2c8a333..cc8901cf38 100644 --- a/esphome/components/emc2101/sensor/__init__.py +++ b/esphome/components/emc2101/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -53,7 +54,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ).extend(cv.polling_component_schema("60s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 0749d7e2a3..1b99491f9c 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_X_GRID, CONF_Y_GRID, ) +from esphome.types import ConfigType CODEOWNERS = ["@synco"] @@ -115,7 +116,9 @@ GRAPH_SCHEMA = cv.Schema( ) -def _relocate_fields_to_subfolder(config, subfolder, subschema): +def _relocate_fields_to_subfolder( + config: ConfigType, subfolder: str, subschema: cv.Schema +) -> ConfigType: fields = [k.schema for k in subschema.schema] fields.remove(CONF_ID) if subfolder in config: @@ -138,7 +141,7 @@ def _relocate_fields_to_subfolder(config, subfolder, subschema): return config -def _relocate_trace(config): +def _relocate_trace(config: ConfigType) -> ConfigType: return _relocate_fields_to_subfolder(config, CONF_TRACES, GRAPH_TRACE_SCHEMA) @@ -148,7 +151,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) cg.add(var.set_width(config[CONF_WIDTH])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 82b98654a2..4ab606d9f9 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -4,6 +4,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,7 +42,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 450f663274..40391206fb 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns @@ -90,7 +91,7 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index f68ca10374..511eb7d542 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( ).extend({cv.Optional(marker): text_sensor.text_sensor_schema() for marker in MARKERS}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 52e9a2c09a..4fff41e4f6 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["uart"] @@ -38,7 +39,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/rd03d/binary_sensor.py b/esphome/components/rd03d/binary_sensor.py index afb7527aa1..2c040d0560 100644 --- a/esphome/components/rd03d/binary_sensor.py +++ b/esphome/components/rd03d/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_RD03D_ID, RD03DComponent @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) if target_config := config.get(CONF_TARGET): diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index 953d99c2da..d29656bab0 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_RD03D_ID, RD03DComponent @@ -75,7 +76,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index c7082794db..0f5ae27753 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] MULTI_CONF = True @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index 55c8195391..26435cfa67 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component @@ -73,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) if ac_voltage_config := config.get(CONF_AC_VOLTAGE): sens = await sensor.new_sensor(ac_voltage_config) diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index f74f89b3b4..eae69fb4df 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATE +from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) if state_config := config.get(CONF_STATE): sens = await text_sensor.new_text_sensor(state_config) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 87c7b9e85c..f9233511e1 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@0hax"] MULTI_CONF = True @@ -34,7 +35,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index 150484d97a..b51d4cb795 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ).extend(TELEINFO_LISTENER_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/teleinfo/text_sensor/__init__.py b/esphome/components/teleinfo/text_sensor/__init__.py index 79fabd10d0..0b6ff11d74 100644 --- a/esphome/components/teleinfo/text_sensor/__init__.py +++ b/esphome/components/teleinfo/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -13,7 +14,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TeleInfoTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await text_sensor.register_text_sensor(var, config) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 51cf3cfd91..ca0ea57796 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -34,7 +35,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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 index 92ae585d96..59583357e4 100644 --- a/esphome/components/ufm01/binary_sensor.py +++ b/esphome/components/ufm01/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import CONF_UFM01_ID, UFM01Component @@ -32,7 +33,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py index 4dcd7ceebe..e3281f0b2d 100644 --- a/esphome/components/ufm01/sensor.py +++ b/esphome/components/ufm01/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CUBIC_METER_PER_HOUR, UNIT_LITRE, ) +from esphome.types import ConfigType from . import CONF_UFM01_ID, UFM01Component @@ -47,7 +48,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) if CONF_ACCUMULATED_FLOW in config: diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 58ce4a30f8..5686b74173 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_XL9535 = "xl9535" @@ -29,13 +31,13 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) -def validate_mode(mode): +def validate_mode(mode: ConfigType) -> ConfigType: if not (mode[CONF_INPUT] or mode[CONF_OUTPUT]) or ( mode[CONF_INPUT] and mode[CONF_OUTPUT] ): @@ -43,7 +45,7 @@ def validate_mode(mode): return mode -def validate_pin(pin): +def validate_pin(pin: int) -> int: if pin in (8, 9): raise cv.Invalid(f"pin {pin} doesn't exist") return pin @@ -67,7 +69,7 @@ XL9535_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_XL9535, XL9535_PIN_SCHEMA) -async def xl9535_pin_to_code(config): +async def xl9535_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_XL9535]) From 44dcd82d78bf4da00ac00f1738d9b95e9735df9e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:58:11 +1200 Subject: [PATCH 186/470] [mipi_spi] Toggle D/C only while holding the SPI bus (#18529) --- esphome/components/mipi_spi/mipi_spi.h | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index b269f46dc9..2552451bd7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,33 +246,36 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - this->dc_pin_->digital_write(false); + // Toggle D/C only while holding the bus; on boards where D/C doubles as + // another bus signal, driving it while another device owns the bus + // corrupts that device's transfer. this->enable(); + this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_array(bytes, len); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); for (size_t i = 0; i != len; i++) { this->enable(); this->write_byte(0); From edd4a86d14a0d4ac64b1b6efc2bcb00cee0d5111 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:15 -0500 Subject: [PATCH 187/470] Bump prek from 0.4.13 to 0.4.14 (#18563) 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 cedc107b17..079c375c01 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.13 # also change in .github/workflows/ci.yml when updating +prek==0.4.14 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 4cfa4893ef4bde01ab73da64470f83eeef2e7461 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:24:28 -0500 Subject: [PATCH 188/470] Bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the docker-actions group (#18564) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 71dedd65aa..f3f7cb30eb 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Determine tag and whether to push id: tag @@ -153,7 +153,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to the GitHub container registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10b28ace38..d0dee8165c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From ece90ee97b11ecfeff40a3c2da11cf357cf381f7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:59:30 -0500 Subject: [PATCH 189/470] Bump bundled esphome-device-builder to 1.12.2 (#18573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 55aa0ac982..2bbe5331e5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.2 RUN \ platformio settings set enable_telemetry No \ From 5177972c041d75bf00351c7f6d2cb250253c19e5 Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:27:44 +0200 Subject: [PATCH 190/470] [runtime_image] keep decoder allocated (#18488) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 2 +- esphome/components/runtime_image/__init__.py | 5 + .../components/runtime_image/bmp_decoder.cpp | 21 +- .../components/runtime_image/bmp_decoder.h | 12 +- .../components/runtime_image/image_decoder.h | 34 +- .../components/runtime_image/image_format.h | 19 + .../components/runtime_image/jpeg_decoder.cpp | 6 - .../components/runtime_image/jpeg_decoder.h | 3 +- .../components/runtime_image/png_decoder.cpp | 7 +- .../components/runtime_image/png_decoder.h | 8 + .../runtime_image/runtime_image.cpp | 53 +-- .../components/runtime_image/runtime_image.h | 34 +- .../sendspin/image/sendspin_image.cpp | 4 +- tests/components/runtime_image/__init__.py | 15 + .../runtime_image/test_decoder_reuse.cpp | 336 ++++++++++++++++++ 15 files changed, 487 insertions(+), 72 deletions(-) create mode 100644 esphome/components/runtime_image/image_format.h create mode 100644 tests/components/runtime_image/__init__.py create mode 100644 tests/components/runtime_image/test_decoder_reuse.cpp diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 22bce4cc41..fe4f727cd6 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -216,7 +216,7 @@ void OnlineImage::loop() { } void OnlineImage::end_connection_() { - // Abort any in-progress decode to free decoder resources. + // Abort any in-progress decode; the decoder object is kept warm for the next decode. // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release(). if (this->is_decoding()) { RuntimeImage::release(); diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 9fa32a5a65..3c130a7d75 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -77,6 +77,11 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_host: + # JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only + # predefines the lowercase __linux__; without this a Linux host + # build tries to include Arduino.h. + cg.add_build_flag("-D__LINUX__") if CORE.is_esp32: from esphome.components.esp32 import add_idf_component diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 6a1bd61d86..5d45621fb7 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -12,6 +12,22 @@ namespace esphome::runtime_image { static const char *const TAG = "image_decoder.bmp"; +void BmpDecoder::reset() { + ImageDecoder::reset(); + this->bits_per_pixel_ = 0; + this->compression_method_ = 0; + this->image_data_size_ = 0; + this->width_ = 0; + this->height_ = 0; + this->current_index_ = 0; + this->paint_index_ = 0; + // color_table_ is deliberately kept allocated so the next decode can reuse it + this->color_table_entries_ = 0; + this->data_offset_ = 0; + this->padding_bytes_ = 0; + this->width_bytes_ = 0; +} + int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t index = 0; if (this->current_index_ == 0) { @@ -85,7 +101,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t header_size = encode_uint32(buffer[17], buffer[16], buffer[15], buffer[14]); size_t offset = 14 + header_size; - this->color_table_ = std::make_unique(this->color_table_entries_); + if (this->color_table_entries_ > this->color_table_capacity_) { + this->color_table_ = std::make_unique(this->color_table_entries_); + this->color_table_capacity_ = this->color_table_entries_; + } for (size_t i = 0; i < this->color_table_entries_; i++) { this->color_table_[i] = encode_uint32(buffer[offset + i * 4 + 3], buffer[offset + i * 4 + 2], diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index a52a561584..01acc41f91 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -21,8 +21,9 @@ class BmpDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - BmpDecoder(RuntimeImage *image) : ImageDecoder(image) {} + BmpDecoder(RuntimeImage *image) : ImageDecoder(image, BMP) {} + void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { @@ -35,17 +36,18 @@ class BmpDecoder : public ImageDecoder { } protected: + std::unique_ptr color_table_; size_t current_index_{0}; size_t paint_index_{0}; ssize_t width_{0}; ssize_t height_{0}; - uint16_t bits_per_pixel_{0}; + size_t width_bytes_{0}; + size_t data_offset_{0}; uint32_t compression_method_{0}; uint32_t image_data_size_{0}; uint32_t color_table_entries_{0}; - std::unique_ptr color_table_; - size_t width_bytes_{0}; - size_t data_offset_{0}; + uint32_t color_table_capacity_{0}; // Allocated entries in color_table_, kept across decodes + uint16_t bits_per_pixel_{0}; uint8_t padding_bytes_{0}; }; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 6d351a10aa..2a8b393888 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -1,5 +1,6 @@ #pragma once #include "esphome/core/color.h" +#include "image_format.h" namespace esphome::runtime_image { @@ -36,18 +37,41 @@ class ImageDecoder { * @brief Construct a new Image Decoder object * * @param image The RuntimeImage to decode the stream into. + * @param format The image format this decoder handles. */ - ImageDecoder(RuntimeImage *image) : image_(image) {} + ImageDecoder(RuntimeImage *image, ImageFormat format) : image_(image), format_(format) {} virtual ~ImageDecoder() = default; + /// @brief Get the image format handled by this decoder. + ImageFormat get_format() const { return this->format_; } + + /// @brief Check if a decoding session is in progress (prepare() called, reset() not yet). + bool is_active() const { return this->active_; } + /** - * @brief Initialize the decoder. + * @brief Reset the decoder state, ending any decoding session. + * Subclasses should override this method to reset any format-specific state. + * Buffers the next decode can reuse should be kept allocated to avoid heap churn. + */ + virtual void reset() { + this->active_ = false; + this->expected_size_ = 0; + this->decoded_bytes_ = 0; + this->size_valid_ = true; + this->x_scale_ = 1.0; + this->y_scale_ = 1.0; + } + + /** + * @brief Initialize the decoder, starting a new decoding session. * * @param expected_size Hint about the expected data size (0 if unknown). * @return int Returns 0 on success, a {@see DecodeError} value in case of an error. */ virtual int prepare(size_t expected_size) { + this->reset(); this->expected_size_ = expected_size; + this->active_ = true; return 0; } @@ -103,11 +127,13 @@ class ImageDecoder { } protected: + double x_scale_ = 1.0; + double y_scale_ = 1.0; RuntimeImage *image_; size_t expected_size_ = 0; // Expected data size (0 if unknown) size_t decoded_bytes_ = 0; // Bytes processed so far - double x_scale_ = 1.0; - double y_scale_ = 1.0; + const ImageFormat format_; + bool active_ = false; // A decoding session is in progress bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h new file mode 100644 index 0000000000..524e52d7bc --- /dev/null +++ b/esphome/components/runtime_image/image_format.h @@ -0,0 +1,19 @@ +#pragma once + +namespace esphome::runtime_image { + +/** + * @brief Image format types that can be decoded dynamically. + */ +enum ImageFormat { + /** Automatically detect from data. Not implemented yet. */ + AUTO, + /** JPEG format. */ + JPEG, + /** PNG format. */ + PNG, + /** BMP format. */ + BMP, +}; + +} // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index c46e86fd0d..85ec945259 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -52,12 +52,6 @@ static int draw_callback(JPEGDRAW *jpeg) { return 1; } -int JpegDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); - // JPEG decoder needs complete data before decoding - return 0; -} - int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { // JPEG decoder requires complete data // If we know the expected size, wait for it diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index ed2401e263..67c9b77f4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - JpegDecoder(RuntimeImage *image) : ImageDecoder(image) {} + JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {} ~JpegDecoder() override {} - int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; protected: diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 9501702711..106f25bbe1 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -48,7 +48,7 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui } } -PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { +PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image, PNG) { { RAMAllocator allocator; pngle_t *pngle = allocator.allocate(1, PNGLE_T_SIZE); @@ -57,8 +57,8 @@ PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { return; } memset(pngle, 0, PNGLE_T_SIZE); - pngle_reset(pngle); this->pngle_ = pngle; + pngle_reset(this->pngle_); } } @@ -71,11 +71,12 @@ PngDecoder::~PngDecoder() { } int PngDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); + // Check before the base prepare() so a failure never leaves an active session if (!this->pngle_) { ESP_LOGE(TAG, "PNG decoder engine not initialized!"); return DECODE_ERROR_OUT_OF_MEMORY; } + ImageDecoder::prepare(expected_size); pngle_set_user_data(this->pngle_, this); pngle_set_init_callback(this->pngle_, init_callback); pngle_set_draw_callback(this->pngle_, draw_callback); diff --git a/esphome/components/runtime_image/png_decoder.h b/esphome/components/runtime_image/png_decoder.h index 24521d33a8..a1cd60e0a6 100644 --- a/esphome/components/runtime_image/png_decoder.h +++ b/esphome/components/runtime_image/png_decoder.h @@ -22,6 +22,14 @@ class PngDecoder : public ImageDecoder { PngDecoder(RuntimeImage *image); ~PngDecoder() override; + void reset() override { + ImageDecoder::reset(); + if (this->pngle_) { + pngle_reset(this->pngle_); + } + this->pixels_decoded_ = 0; + } + int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 8fe9be4c8c..e269f7d8f3 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -172,33 +172,38 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, } bool RuntimeImage::begin_decode(size_t expected_size) { - if (this->decoder_) { + if (this->is_decoding()) { ESP_LOGW(TAG, "Decoding already in progress"); return false; } - this->decoder_ = this->create_decoder_(); + // An idle decoder for a different format cannot be reused + if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) { + ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_); + this->decoder_ = nullptr; + } + if (!this->decoder_) { - ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); - return false; + this->decoder_ = this->create_decoder_(this->format_); + if (!this->decoder_) { + ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); + return false; + } } - this->total_size_ = expected_size; this->decoded_bytes_ = 0; - // Initialize decoder int result = this->decoder_->prepare(expected_size); if (result < 0) { ESP_LOGE(TAG, "Failed to prepare decoder: %d", result); - this->decoder_ = nullptr; + this->decoder_ = nullptr; // If prepare fails, a full reset is needed return false; } - return true; } int RuntimeImage::feed_data(uint8_t *data, size_t len) { - if (!this->decoder_) { + if (!this->is_decoding()) { ESP_LOGE(TAG, "No decoder initialized"); return -1; } @@ -212,7 +217,7 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) { } bool RuntimeImage::end_decode() { - if (!this->decoder_) { + if (!this->is_decoding()) { return false; } @@ -224,26 +229,23 @@ bool RuntimeImage::end_decode() { this->data_start_ = this->buffer_; } - // Clean up decoder - this->decoder_ = nullptr; + // End the session; the decoder object stays warm so the next decode can + // reuse it (and its buffers) without churning the heap. + this->decoder_->reset(); ESP_LOGD(TAG, "Decoding complete: %dx%d, %zu bytes", this->width_, this->height_, this->decoded_bytes_); return true; } -bool RuntimeImage::is_decode_finished() const { - if (!this->decoder_) { - return false; - } - return this->decoder_->is_finished(); -} +bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } void RuntimeImage::release() { this->release_buffer_(); - // Reset decoder separately — release() can be called from within the decoder - // (via set_size -> resize -> resize_buffer_), so we must not destroy the decoder here. - // The decoder lifecycle is managed by begin_decode()/end_decode(). - this->decoder_ = nullptr; + // End any active decode session; decoders free the format-specific working buffers + // they can (PNG), while the decoder object itself is kept warm for the next decode. + if (this->decoder_) { + this->decoder_->reset(); + } } void RuntimeImage::release_buffer_() { @@ -347,8 +349,9 @@ size_t RuntimeImage::get_buffer_size(int width, int height) const { int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } -std::unique_ptr RuntimeImage::create_decoder_() { - switch (this->format_) { +std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) { + ESP_LOGV(TAG, "Creating decoder for format %d", format); + switch (format) { #ifdef USE_RUNTIME_IMAGE_BMP case BMP: return make_unique(this); @@ -362,7 +365,7 @@ std::unique_ptr RuntimeImage::create_decoder_() { return make_unique(this); #endif default: - ESP_LOGE(TAG, "Unsupported image format: %d", this->format_); + ESP_LOGE(TAG, "Unsupported image format: %d", format); return nullptr; } } diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 10ce980be2..cfac253fdb 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -3,25 +3,11 @@ #include "esphome/components/image/image.h" #include "esphome/core/helpers.h" +#include "image_decoder.h" +#include "image_format.h" + namespace esphome::runtime_image { -// Forward declaration -class ImageDecoder; - -/** - * @brief Image format types that can be decoded dynamically. - */ -enum ImageFormat { - /** Automatically detect from data. Not implemented yet. */ - AUTO, - /** JPEG format. */ - JPEG, - /** PNG format. */ - PNG, - /** BMP format. */ - BMP, -}; - /** * @brief A dynamic image that can be loaded and decoded at runtime. * @@ -99,7 +85,7 @@ class RuntimeImage : public image::Image { /** * @brief Check if decoding is currently in progress. */ - bool is_decoding() const { return this->decoder_ != nullptr; } + bool is_decoding() const { return this->decoder_ != nullptr && this->decoder_->is_active(); } /** * @brief Check if the decoder has finished processing all data. @@ -120,9 +106,10 @@ class RuntimeImage : public image::Image { ImageFormat get_format() const { return this->format_; } /** - * @brief Release the image buffer and free memory. + * @brief Release the image buffer and free its memory, ending any decode session. * - * An external buffer is let go of rather than freed. + * An external buffer is let go of rather than freed. The decoder object is kept + * warm so the next decode can reuse it without churning the heap. */ void release(); @@ -194,9 +181,11 @@ class RuntimeImage : public image::Image { int get_position_(int x, int y) const; /** - * @brief Create decoder instance for the image's format. + * @brief Create decoder instance for the requested format. + * @param format The image format to decode. + * @return Unique pointer to the created decoder, or nullptr on failure. */ - std::unique_ptr create_decoder_(); + std::unique_ptr create_decoder_(ImageFormat format); // Memory management uint8_t *buffer_{nullptr}; @@ -224,7 +213,6 @@ class RuntimeImage : public image::Image { int buffer_height_{0}; // Decoding state - size_t total_size_{0}; size_t decoded_bytes_{0}; /** Fixed width requested on configuration, or 0 if not specified. */ diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 626d7966b7..558a292d5b 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -86,8 +86,8 @@ void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { } const bool decoded = this->decode_frame_(data, length, target); - // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is - // safe on every path. + // Ends any half-finished decode session (the decoder object is kept for reuse). An external + // buffer is let go of rather than freed, so this is safe on every path. this->decode_sink_.release(); if (!decoded) { diff --git a/tests/components/runtime_image/__init__.py b/tests/components/runtime_image/__init__.py new file mode 100644 index 0000000000..a8ff4bb68e --- /dev/null +++ b/tests/components/runtime_image/__init__.py @@ -0,0 +1,15 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in cpptest builds; formats are normally enabled by + # process_runtime_image_config(). Enable all formats so the format-switch + # tests have two decoder types and every retained decoder is under test. + async def to_code_testing(config: ConfigType) -> None: + enable_format("BMP") + enable_format("PNG") + enable_format("JPEG") + + manifest.to_code = to_code_testing diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp new file mode 100644 index 0000000000..87e77b00be --- /dev/null +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -0,0 +1,336 @@ +#include +#include + +#include +#include +#include +#include + +#include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::testing { + +// 3x2 24bpp BMP, every pixel a unique color (rows padded to 4 bytes) +static const uint8_t BMP_24BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00, + 0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00, +}; + +static const uint8_t BMP_24BPP_EXPECTED[2][3][3] = { + {{0xE0, 0x10, 0x20}, {0x30, 0xC0, 0x40}, {0x50, 0x60, 0xA0}}, + {{0x11, 0x22, 0x33}, {0x99, 0x88, 0x77}, {0xAB, 0xCD, 0xEF}}, +}; + +// 3x2 8bpp BMP with a 4-entry color table +static const uint8_t BMP_8BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x20, 0x10, 0x00, 0xD0, 0xE0, 0xF0, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x03, 0x02, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, +}; + +static const uint8_t BMP_8BPP_EXPECTED[2][3][3] = { + {{0x10, 0x20, 0x30}, {0xF0, 0xE0, 0xD0}, {0x00, 0xFF, 0x00}}, + {{0xFF, 0x00, 0xFF}, {0x00, 0xFF, 0x00}, {0xF0, 0xE0, 0xD0}}, +}; + +// 3x2 8bpp BMP with an 8-entry color table, all colors distinct from BMP_8BPP's +static const uint8_t BMP_8BPP_BIG[] = { + 0x42, 0x4D, 0x5E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x18, 0x08, + 0x00, 0xA8, 0xB8, 0xC8, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x55, 0x99, + 0x11, 0x00, 0xCC, 0x00, 0x66, 0x00, 0x44, 0x22, 0xEE, 0x00, 0x01, 0x06, 0x04, 0x00, 0x07, 0x05, 0x03, 0x00, +}; + +static const uint8_t BMP_8BPP_BIG_EXPECTED[2][3][3] = { + {{0xEE, 0x22, 0x44}, {0x11, 0x99, 0x55}, {0x80, 0xFF, 0x00}}, + {{0xC8, 0xB8, 0xA8}, {0x66, 0x00, 0xCC}, {0xFF, 0x80, 0x00}}, +}; + +// 4x4 RGB PNG, every pixel a unique color +static const uint8_t PNG_RGB[] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x04, 0x08, 0x02, 0x00, 0x00, 0x00, 0x26, 0x93, 0x09, 0x29, 0x00, 0x00, 0x00, 0x38, 0x49, + 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x16, 0x50, 0x30, 0x58, 0xB0, 0xE1, 0xC0, 0xFF, 0xFF, 0x0C, + 0x0C, 0x0E, 0x0C, 0x50, 0xEC, 0xE0, 0xE0, 0xC0, 0x50, 0xCF, 0xF0, 0x9F, 0xA1, 0xFE, 0xFF, 0xFF, 0x7A, 0x86, 0xFA, + 0xFF, 0x0C, 0x0C, 0x42, 0x26, 0x61, 0xA9, 0xCE, 0x8A, 0xFF, 0xEE, 0xEC, 0x5A, 0x7D, 0xF6, 0x3D, 0x00, 0x81, 0xCB, + 0x12, 0x4D, 0xB3, 0xFB, 0xD4, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +}; + +static const uint8_t PNG_RGB_EXPECTED[4][4][3] = { + {{0x01, 0x02, 0x03}, {0x10, 0x20, 0x30}, {0xA0, 0xB0, 0xC0}, {0xFF, 0xFF, 0x00}}, + {{0x40, 0x00, 0x00}, {0x00, 0x40, 0x00}, {0x00, 0x00, 0x40}, {0x40, 0x40, 0x40}}, + {{0x7F, 0x00, 0xFF}, {0x00, 0x7F, 0xFF}, {0xFF, 0x7F, 0x00}, {0x7F, 0xFF, 0x00}}, + {{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}}, +}; + +/// Exposes the protected decoder machinery so reuse and eviction can be observed directly. +class TestableRuntimeImage : public RuntimeImage { + public: + explicit TestableRuntimeImage(ImageFormat format) + : RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {} + + ImageDecoder *decoder() { return this->decoder_.get(); } + + /// Simulates the state a dynamic-format producer (PR #16337) would leave behind: + /// a cached decoder whose format no longer matches the image's format. + /// TODO: once #16337 adds a public way to change the format, drive the mismatch + /// through it and delete this seam. + void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); } +}; + +/// Runs one full decode session. Returns true when every stage succeeded. +static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) { + std::vector buffer(data, data + len); // feed_data needs mutable bytes + if (!img.begin_decode(len)) { + return false; + } + size_t offset = 0; + while (offset < len) { + int consumed = img.feed_data(buffer.data() + offset, len - offset); + if (consumed <= 0) { + return false; // decode error, or no progress despite full data + } + offset += consumed; + } + return img.end_decode(); +} + +/// Feeds the image the way online_image's download loop does: append a small +/// chunk to a window, feed the window, drop what was consumed, repeat. A zero +/// return mid-stream means "need more data" and grows the window. +static bool decode_chunked(TestableRuntimeImage &img, const uint8_t *data, size_t len, size_t chunk_size) { + if (!img.begin_decode(len)) { + return false; + } + std::vector window; + size_t supplied = 0; + while (supplied < len || !window.empty()) { + if (supplied < len) { + size_t take = std::min(chunk_size, len - supplied); + window.insert(window.end(), data + supplied, data + supplied + take); + supplied += take; + } + int consumed = img.feed_data(window.data(), window.size()); + if (consumed < 0 || (consumed == 0 && supplied >= len)) { + return false; // decode error, or stuck with all data supplied + } + window.erase(window.begin(), window.begin() + consumed); + } + return img.end_decode(); +} + +template static void expect_pixels(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][3]) { + ASSERT_EQ(img.get_width(), static_cast(W)); + ASSERT_EQ(img.get_height(), static_cast(H)); + for (size_t y = 0; y < H; y++) { + for (size_t x = 0; x < W; x++) { + SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")"); + Color color = img.get_pixel(x, y); + EXPECT_THAT((std::array{color.r, color.g, color.b}), ::testing::ElementsAreArray(expected[y][x])); + } + } +} + +TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated"; +} + +TEST(RuntimeImageDecoder, SecondDecodeStartsClean) { + TestableRuntimeImage img(BMP); + + // Palettized decode, then a 24bpp decode, then palettized again, all on the + // same decoder: each session must produce correct pixels for its own image. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ColorTableGrowsAndShrinksAcrossReuse) { + TestableRuntimeImage img(BMP); + + // Small palette first: the retained table is allocated at 4 entries. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Growing to 8 entries on the reused decoder must reallocate, not overflow. + ASSERT_TRUE(decode_all(img, BMP_8BPP_BIG, sizeof(BMP_8BPP_BIG))); + expect_pixels(img, BMP_8BPP_BIG_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + // Shrinking back must not surface stale colors from the larger table. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Chunked again on the warm decoder: the cross-call resume state + // (current_index_ / paint_index_) must have been fully reset. + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) { + // PNG image holding a stale BMP decoder: begin_decode must evict and recreate. + TestableRuntimeImage png_img(PNG); + png_img.plant_decoder(BMP); + ASSERT_NE(png_img.decoder(), nullptr); + ASSERT_EQ(png_img.decoder()->get_format(), BMP); + + ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB))); + EXPECT_EQ(png_img.decoder()->get_format(), PNG); + expect_pixels(png_img, PNG_RGB_EXPECTED); + + // And the other direction: BMP image holding a stale PNG decoder. + TestableRuntimeImage bmp_img(BMP); + bmp_img.plant_decoder(PNG); + ASSERT_NE(bmp_img.decoder(), nullptr); + ASSERT_EQ(bmp_img.decoder()->get_format(), PNG); + + ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP))); + EXPECT_EQ(bmp_img.decoder()->get_format(), BMP); + expect_pixels(bmp_img, BMP_24BPP_EXPECTED); +} + +TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) { + TestableRuntimeImage img(PNG); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + img.release(); + EXPECT_EQ(img.decoder(), first) << "release() must keep the decoder for reuse"; + EXPECT_FALSE(img.is_decoding()); + EXPECT_EQ(img.get_width(), 0); + EXPECT_EQ(img.get_height(), 0); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + expect_pixels(img, PNG_RGB_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FailedDecodeRecovers) { + TestableRuntimeImage img(BMP); + + uint8_t garbage[32]; + memset(garbage, 'X', sizeof(garbage)); + ASSERT_TRUE(img.begin_decode(sizeof(garbage))); + EXPECT_LT(img.feed_data(garbage, sizeof(garbage)), 0) << "garbage must fail to decode"; + img.release(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); +} + +#ifdef USE_RUNTIME_IMAGE_JPEG +// 8x8 gradient JPEG (quality 90). JPEG is lossy, so the test asserts that a +// reused decoder reproduces the exact same pixels, not absolute colors. +static const uint8_t JPEG_GRADIENT[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, + 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A, + 0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, + 0x15, 0x15, 0x0C, 0x0F, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x03, + 0x04, 0x04, 0x05, 0x04, 0x05, 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xE5, 0x3E, 0x0B, 0xFE, + 0xC8, 0x7F, 0xEA, 0x3F, 0xD0, 0xBD, 0x3F, 0x86, 0x8A, 0x28, 0xAA, 0xC2, 0x62, 0x6A, 0xFB, 0x25, 0xA9, 0xD5, 0xC0, + 0x7C, 0x6B, 0x9D, 0x7F, 0x62, 0xD3, 0xFD, 0xEF, 0xF5, 0xF7, 0x9F, 0xFF, 0xD9, +}; + +static std::vector pixel_bytes(TestableRuntimeImage &img) { + const uint8_t *start = img.get_data_start(); + return std::vector(start, start + img.get_width_stride() * img.get_height()); +} + +TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(JPEG); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(img.get_width(), 8); + ASSERT_EQ(img.get_height(), 8); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; +} +#endif // USE_RUNTIME_IMAGE_JPEG + +TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) { + TestableRuntimeImage img(BMP); + std::vector buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP)); + + ASSERT_TRUE(img.begin_decode(buffer.size())); + EXPECT_TRUE(img.is_decoding()); + EXPECT_FALSE(img.is_decode_finished()); + + ASSERT_EQ(img.feed_data(buffer.data(), buffer.size()), static_cast(buffer.size())); + EXPECT_TRUE(img.is_decode_finished()) << "all pixel data consumed"; + + ASSERT_TRUE(img.end_decode()); + EXPECT_FALSE(img.is_decoding()) << "end_decode() must close the session"; + EXPECT_FALSE(img.is_decode_finished()) << "no session means nothing is 'finished'"; +} + +} // namespace esphome::runtime_image::testing From 46f90d0c54af2ce42af6aa55dcdcdbaf2717e5ae Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:28:17 +0200 Subject: [PATCH 191/470] [core] Add portable strcasestr implementation named str_contains_ignore_case (#18497) Co-authored-by: J. Nick Koston --- esphome/components/audio/audio.cpp | 2 +- esphome/core/helpers.cpp | 13 ++++++++ esphome/core/helpers.h | 19 +++++++++++ tests/components/core/helpers_test.cpp | 46 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index b0aa3c1abb..402e741059 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -86,7 +86,7 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url) // Match "audio/ogg" with a codecs parameter containing "opus" // Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc. // Plain "audio/ogg" without opus is not matched (almost always Ogg Vorbis) - if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) { + if (strncasecmp(content_type, "audio/ogg", 9) == 0 && str_contains_ignore_case(content_type + 9, "opus")) { return AudioFileType::OPUS; } #endif diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index bd08d3b63e..a276020be4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -220,6 +220,19 @@ bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffi return strncasecmp(str + str_len - suffix_len, suffix, suffix_len) == 0; } +bool str_contains_ignore_case_fallback(const char *haystack, const char *needle) { + const size_t needle_len = strlen(needle); + if (needle_len == 0) { + return true; + } + for (const char *p = haystack; *p != '\0'; p++) { + if (strncasecmp(p, needle, needle_len) == 0) { + return true; + } + } + return false; +} + // str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 994fa2c26a..5a9c120b84 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -981,6 +981,25 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) return str_endswith_ignore_case(str.c_str(), str.size(), suffix, strlen(suffix)); } +/// Fallback implementation for case insensitive substring comparison. +bool str_contains_ignore_case_fallback(const char *haystack, const char *needle); + +/// Case-insensitive check if needle string is contained in haystack (no heap allocation). +inline bool str_contains_ignore_case(const char *haystack, const char *needle) { + if (!needle || !haystack) { + return false; + } + +// strcasestr is a GNU extension: newlib only declares it when _GNU_SOURCE is set. +// ESP32/ESP8266/host builds get it from their framework or from g++ on Linux; +// LibreTiny, RP2 and Zephyr do not, so they use the hand-rolled fallback. +#if defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) + return str_contains_ignore_case_fallback(haystack, needle); +#else // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) + return strcasestr(haystack, needle) != nullptr; +#endif // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) +} + // str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 // str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index a9a940392f..d5219f9d47 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -83,4 +83,50 @@ TEST(StaticVectorTest, ConvertingConstructorSameSize) { EXPECT_EQ(dst[2], 3); } +TEST(StringContainsIgnoreCaseTest, NullPointerAlwaysFalse) { + const char *haystack = nullptr; + const char *needle = nullptr; + + EXPECT_FALSE(str_contains_ignore_case(haystack, needle)); + EXPECT_FALSE(str_contains_ignore_case("Hello World", needle)); + EXPECT_FALSE(str_contains_ignore_case(haystack, "anything")); +} + +TEST(StringContainsIgnoreCaseTest, EmptySearchMatches) { + const char *haystack = "Hello World"; + + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "")); +} + +TEST(StringContainsIgnoreCaseTest, MiscCaseMatches) { + const char *haystack = "Hello World"; + + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hello")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hello")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "HELLO")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hELLO")); +} + +TEST(StringContainsIgnoreCaseTest, MiscNotMatching) { + const char *haystack = "Hello World"; + + // Expected to match + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hell")); + + // Expected not to match + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Heaven")); + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Hello!")); + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "world!")); +} + +TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) { + const char *haystack = "Hello World"; + for (const char *needle : {"", "Hello", "hELLO", "Hell", "world", "Heaven", "Hello!", "d"}) { + EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle)) + << "needle: " << needle; + } + EXPECT_EQ(str_contains_ignore_case_fallback("", ""), str_contains_ignore_case("", "")); + EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc")); +} + } // namespace esphome From 52bfc0efb1c574324910c5d0c1de628a4bcc1147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 18:06:23 -0500 Subject: [PATCH 192/470] [espnow] Fix dump_config crash when enable_on_boot is false (#18572) --- esphome/components/espnow/espnow_component.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index df9a1b8668..ecf0f79e4a 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -129,14 +129,17 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { - uint32_t version = 0; - esp_now_get_version(&version); - ESP_LOGCONFIG(TAG, "espnow:"); - if (this->is_disabled()) { - ESP_LOGCONFIG(TAG, " Disabled"); + // Only report driver details once enabled; with enable_on_boot: false the + // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // and after a failed enable_() the values would be meaningless. + if (this->state_ != ESPNOW_STATE_ENABLED) { + // OFF here means enable_() failed; the core logs the FAILED marker separately + ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } + uint32_t version = 0; + esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, From 7957808f00eec1eac78e40cd59dac8815ae7c55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:57:58 +0200 Subject: [PATCH 193/470] [emontx] Fix sensor state_class defaults not being applied correctly (#17610) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- esphome/components/emontx/sensor/__init__.py | 63 ++++++----- tests/component_tests/emontx/__init__.py | 0 .../emontx/test_sensor_defaults.py | 100 ++++++++++++++++++ tests/components/emontx/test.esp32-idf.yaml | 3 +- tests/components/emontx/test.esp8266-ard.yaml | 3 +- tests/components/emontx/test.rp2040-ard.yaml | 3 +- .../components/emontx/validate.esp32-idf.yaml | 73 +++++++++++++ 7 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/emontx/__init__.py create mode 100644 tests/component_tests/emontx/test_sensor_defaults.py create mode 100644 tests/components/emontx/validate.esp32-idf.yaml diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 83a972c5e0..967bc4e699 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -68,6 +68,7 @@ PATTERN_CONFIGS = { "PULSE": { CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, CONF_ACCURACY_DECIMALS: 0, }, "PF": { @@ -78,12 +79,13 @@ PATTERN_CONFIGS = { }, } -# Create a base schema that's flexible for any tag -BASE_SCHEMA = sensor.sensor_schema( - EmonTxSensor, - state_class=STATE_CLASS_MEASUREMENT, - accuracy_decimals=0, -).extend( +# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults. +# Passing them to sensor_schema() would register them via cv.Optional(key, default=...), +# making them always present in the validated config dict and preventing +# apply_tag_defaults from overriding them with the correct per-prefix values. +# They are injected by apply_tag_defaults below, after running through +# sensor.validate_state_class() so the value is code-generation-ready. +BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend( { cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), cv.Required(CONF_TAG_NAME): cv.string, @@ -91,34 +93,43 @@ BASE_SCHEMA = sensor.sensor_schema( ) +def _apply_defaults(config: ConfigType, defaults: dict) -> None: + """Inject defaults into config, skipping keys already set by the user. + state_class values are run through validate_state_class so they are + code-generation-ready, matching what sensor_schema() would normally do.""" + for key, value in defaults.items(): + if key not in config: + if key == CONF_STATE_CLASS: + value = sensor.validate_state_class(value) + config[key] = value + + def apply_tag_defaults(config: ConfigType) -> ConfigType: """Apply defaults based on tag prefix if applicable, but don't restrict any tags.""" tag = config[CONF_TAG_NAME] - # Skip if tag is too short - if len(tag) < 2: - return config + if len(tag) >= 2: + tag_upper = tag.upper() - # Check if this tag starts with a known prefix - tag_upper = tag.upper() + for pattern, pattern_config in PATTERN_CONFIGS.items(): + if tag_upper.startswith(pattern): + _apply_defaults(config, pattern_config) + return config - for pattern, pattern_config in PATTERN_CONFIGS.items(): - if tag_upper.startswith(pattern): - # Apply pattern defaults if not overridden by user - for key, value in pattern_config.items(): - if key not in config: - config[key] = value + # Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3) + prefix = tag_upper[0] + if prefix in SENSOR_CONFIGS and tag[1:].isdigit(): + _apply_defaults(config, SENSOR_CONFIGS[prefix]) return config - # Only apply defaults for known prefixes with numeric indices - prefix = tag_upper[0] - if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit(): - # Apply defaults for known tag types, but only if not overridden by user - defaults = SENSOR_CONFIGS[prefix] - for key, value in defaults.items(): - if key not in config: - config[key] = value - + # Fall back to generic defaults for tags with no known prefix + _apply_defaults( + config, + { + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 0, + }, + ) return config diff --git a/tests/component_tests/emontx/__init__.py b/tests/component_tests/emontx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/emontx/test_sensor_defaults.py b/tests/component_tests/emontx/test_sensor_defaults.py new file mode 100644 index 0000000000..00d24d282e --- /dev/null +++ b/tests/component_tests/emontx/test_sensor_defaults.py @@ -0,0 +1,100 @@ +"""Tests for emontx sensor tag defaults.""" + +import pytest + +from esphome.components import sensor +from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) + + +def _resolve_via_config_schema(tag: str) -> dict: + """Run a minimal config through the real CONFIG_SCHEMA pipeline, the + same path a user's YAML goes through.""" + return CONFIG_SCHEMA( + {"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"} + ) + + +def test_config_schema_applies_tag_default_state_class(): + """If sensor_schema(state_class=...) is reintroduced, the schema-level + default wins over apply_tag_defaults' per-prefix value, and E1 would + resolve to measurement instead of total_increasing. Driving the real + CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since + sensor_schema() runs before apply_tag_defaults in the cv.All() chain. + """ + result = _resolve_via_config_schema("E1") + assert result[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_TOTAL_INCREASING + ) + + +def test_config_schema_applies_tag_default_accuracy_decimals(): + """Same root cause as the state_class regression: reintroducing + sensor_schema(accuracy_decimals=...) would make V1 resolve to the + schema-level default instead of the prefix-specific value of 2. + """ + result = _resolve_via_config_schema("V1") + assert result[CONF_ACCURACY_DECIMALS] == 2 + + +def _make_config(tag: str) -> dict: + """Minimal config dict with only tag_name set — no overrides.""" + return {"tag_name": tag} + + +@pytest.mark.parametrize( + ("tag", "expected_state_class", "expected_decimals"), + [ + # Known numeric-index prefixes + ("E1", STATE_CLASS_TOTAL_INCREASING, 0), + ("E12", STATE_CLASS_TOTAL_INCREASING, 0), + ("P1", STATE_CLASS_MEASUREMENT, 0), + ("V1", STATE_CLASS_MEASUREMENT, 2), + ("I1", STATE_CLASS_MEASUREMENT, 2), + ("T1", STATE_CLASS_MEASUREMENT, 2), + # Known patterns + ("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0), + ("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0), + ("PF1", STATE_CLASS_MEASUREMENT, 2), + # Unknown / free-form tags fall back to generic defaults + ("CUSTOM1", STATE_CLASS_MEASUREMENT, 0), + ("X", STATE_CLASS_MEASUREMENT, 0), + ], +) +def test_apply_tag_defaults(tag, expected_state_class, expected_decimals): + """apply_tag_defaults must inject the correct state_class and accuracy_decimals + for each tag type when no user overrides are present.""" + config = _make_config(tag) + result = apply_tag_defaults(config) + + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class) + assert result[CONF_ACCURACY_DECIMALS] == expected_decimals + + +@pytest.mark.parametrize( + ("tag", "user_state_class", "user_decimals"), + [ + # User overrides must not be clobbered by defaults + ("E1", STATE_CLASS_MEASUREMENT, 3), + ("PULSE1", STATE_CLASS_MEASUREMENT, 1), + ("V1", STATE_CLASS_TOTAL_INCREASING, 0), + ("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4), + ], +) +def test_apply_tag_defaults_respects_user_overrides( + tag, user_state_class, user_decimals +): + """apply_tag_defaults must not overwrite values already set by the user.""" + config = _make_config(tag) + config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class) + config[CONF_ACCURACY_DECIMALS] = user_decimals + + result = apply_tag_defaults(config) + + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class) + assert result[CONF_ACCURACY_DECIMALS] == user_decimals diff --git a/tests/components/emontx/test.esp32-idf.yaml b/tests/components/emontx/test.esp32-idf.yaml index a0784fcd53..e56b1bda5d 100644 --- a/tests/components/emontx/test.esp32-idf.yaml +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/test.esp8266-ard.yaml b/tests/components/emontx/test.esp8266-ard.yaml index 80a2cb2fc0..9ec9377437 100644 --- a/tests/components/emontx/test.esp8266-ard.yaml +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/test.rp2040-ard.yaml b/tests/components/emontx/test.rp2040-ard.yaml index 410c579d4b..6f4952d8e5 100644 --- a/tests/components/emontx/test.rp2040-ard.yaml +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/validate.esp32-idf.yaml b/tests/components/emontx/validate.esp32-idf.yaml new file mode 100644 index 0000000000..7caee78a07 --- /dev/null +++ b/tests/components/emontx/validate.esp32-idf.yaml @@ -0,0 +1,73 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + emontx: !include common.yaml + +# Validate that each sensor type gets the correct default state_class, +# unit_of_measurement, device_class, and accuracy_decimals when NO overrides +# are provided. The values are intentionally omitted so apply_tag_defaults is +# exercised, not the user-override path. + +sensor: + # Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh, + # device_class=energy, accuracy_decimals=0 + - platform: emontx + tag_name: E1 + name: Energy 1 + emontx_id: test_emontx + + # Power sensor (P prefix): expects state_class=measurement, unit=W, + # device_class=power, accuracy_decimals=0 + - platform: emontx + tag_name: P1 + name: Power 1 + emontx_id: test_emontx + + # Voltage sensor (V prefix): expects state_class=measurement, unit=V, + # device_class=voltage, accuracy_decimals=2 + - platform: emontx + tag_name: V1 + name: Voltage 1 + emontx_id: test_emontx + + # Current sensor (I prefix): expects state_class=measurement, unit=A, + # device_class=current, accuracy_decimals=2 + - platform: emontx + tag_name: I1 + name: Current 1 + emontx_id: test_emontx + + # Temperature sensor (T prefix): expects state_class=measurement, unit=°C, + # device_class=temperature, accuracy_decimals=2 + - platform: emontx + tag_name: T1 + name: Temperature 1 + emontx_id: test_emontx + + # Pulse sensor (PULSE pattern): expects state_class=total_increasing, + # unit=pulses, device_class=energy, accuracy_decimals=0 + - platform: emontx + tag_name: PULSE1 + name: Pulse 1 + emontx_id: test_emontx + + # Power factor sensor (PF pattern): expects state_class=measurement, + # device_class=power_factor, accuracy_decimals=2 + - platform: emontx + tag_name: PF1 + name: Power Factor 1 + emontx_id: test_emontx + + # Unknown tag: no prefix match, falls back to state_class=measurement, + # accuracy_decimals=0 + - platform: emontx + tag_name: CUSTOM1 + name: Custom sensor + emontx_id: test_emontx + + # User override: verify that explicit values are respected and not clobbered + - platform: emontx + tag_name: E2 + name: Energy 2 (user override) + emontx_id: test_emontx + state_class: measurement + accuracy_decimals: 3 From 409d74a48da48ea3152c7d8aedb49f622123782f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:53:44 -0400 Subject: [PATCH 194/470] [esp32_hosted] Fire on_update_available trigger when update is detected (#18591) --- .../esp32_hosted/update/esp32_hosted_update.cpp | 9 +++++++++ .../esp32_hosted/test-embedded.esp32-p4-idf.yaml | 3 +++ .../components/esp32_hosted/test-http.esp32-p4-idf.yaml | 3 +++ 3 files changed, 15 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 351b0869b0..4eb5d1745b 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -135,6 +135,10 @@ void Esp32HostedUpdate::setup() { // Publish state this->status_clear_error(); this->publish_state(); + // Defer so the automation runs on the main loop after setup, not during App.setup() + if (this->state_ == update::UPDATE_STATE_AVAILABLE && this->update_available_trigger_) { + this->defer([this]() { this->update_available_trigger_->trigger(this->update_info_); }); + } #else // HTTP mode: check every 10s until network is ready (max 6 attempts) // Only if update interval is > 1 minute to avoid redundant checks @@ -185,6 +189,8 @@ void Esp32HostedUpdate::check() { return; } + const bool was_available = this->state_ == update::UPDATE_STATE_AVAILABLE; + // Compare versions if (this->update_info_.latest_version.empty() || this->update_info_.latest_version == this->update_info_.current_version) { @@ -197,6 +203,9 @@ void Esp32HostedUpdate::check() { this->update_info_.progress = 0.0f; this->status_clear_error(); this->publish_state(); + if (this->state_ == update::UPDATE_STATE_AVAILABLE && !was_available && this->update_available_trigger_) { + this->update_available_trigger_->trigger(this->update_info_); + } #endif } diff --git a/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml index 9640032b34..5cf33179ba 100644 --- a/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml @@ -6,3 +6,6 @@ update: type: embedded path: $component_dir/test_firmware.bin sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31 + on_update_available: + then: + - logger.log: "Coprocessor update available" diff --git a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml index 17cde0f35d..88b620cfe8 100644 --- a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml @@ -8,3 +8,6 @@ update: type: http source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json update_interval: 6h + on_update_available: + then: + - logger.log: "Coprocessor update available" From aa944456e0ab4531d7b9184d5d97de166d522913 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:11:52 +1200 Subject: [PATCH 195/470] [core] Add type annotations to component Python (6/11) (#18343) --- esphome/components/ags10/sensor.py | 19 ++++++++++--- esphome/components/at581x/__init__.py | 19 ++++++++++--- esphome/components/at581x/switch/__init__.py | 3 ++- esphome/components/canbus/__init__.py | 20 +++++++++----- esphome/components/daly_bms/__init__.py | 3 ++- esphome/components/daly_bms/binary_sensor.py | 6 +++-- esphome/components/daly_bms/sensor.py | 6 +++-- esphome/components/daly_bms/text_sensor.py | 6 +++-- esphome/components/deep_sleep/__init__.py | 21 +++++++++++---- esphome/components/ds1307/time.py | 19 ++++++++++--- .../components/esp32_ble_tracker/__init__.py | 21 ++++++++++----- esphome/components/ethernet/__init__.py | 27 ++++++++++++------- esphome/components/hdc302x/sensor.py | 23 +++++++++++++--- esphome/components/htu21d/sensor.py | 19 ++++++++++--- esphome/components/ld6002b/__init__.py | 2 +- esphome/components/ld6002b/binary_sensor.py | 3 ++- esphome/components/ld6002b/button/__init__.py | 2 +- esphome/components/ld6002b/number/__init__.py | 2 +- esphome/components/ld6002b/select/__init__.py | 3 ++- esphome/components/ld6002b/sensor.py | 3 ++- esphome/components/ld6002b/switch/__init__.py | 3 ++- esphome/components/ld6002b/text_sensor.py | 3 ++- esphome/components/m5stack_8angle/__init__.py | 3 ++- .../m5stack_8angle/binary_sensor/__init__.py | 3 ++- .../m5stack_8angle/light/__init__.py | 3 ++- .../m5stack_8angle/sensor/__init__.py | 3 ++- esphome/components/modbus/__init__.py | 20 ++++++++------ esphome/components/openthread/__init__.py | 25 +++++++++++------ esphome/components/pulse_counter/sensor.py | 21 ++++++++++----- esphome/components/pulse_meter/sensor.py | 21 ++++++++++----- esphome/components/shelly_dimmer/light.py | 11 ++++---- 31 files changed, 246 insertions(+), 97 deletions(-) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 6491d7d810..8606e7c247 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_OHM, UNIT_PARTS_PER_BILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_RESISTANCE = "resistance" @@ -62,7 +65,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz") -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( AGS10_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def ags10newi2caddress_to_code(config, action_id, template_arg, args): +async def ags10newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) @@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( AGS10_SET_ZERO_POINT_SCHEMA, synchronous=True, ) -async def ags10setzeropoint_to_code(config, action_id, template_arg, args): +async def ags10setzeropoint_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) mode = await cg.templatable( diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 5031b72cce..193e62f615 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] DEPENDENCIES = ["i2c"] @@ -70,7 +73,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -91,7 +94,12 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio ), synchronous=True, ) -async def at581x_reset_to_code(config, action_id, template_arg, args): +async def at581x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -163,7 +171,12 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( RADAR_SETTINGS_SCHEMA, synchronous=True, ) -async def at581x_settings_to_code(config, action_id, template_arg, args): +async def at581x_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/at581x/switch/__init__.py b/esphome/components/at581x/switch/__init__.py index 8e1b82b356..7e45ed89ec 100644 --- a/esphome/components/at581x/switch/__init__.py +++ b/esphome/components/at581x/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI +from esphome.types import ConfigType from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: at581x_component = await cg.get_variable(config[CONF_AT581X_ID]) s = await switch.new_switch(config) await cg.register_parented(s, config[CONF_AT581X_ID]) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index fcd342ad38..b7de235dd1 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,10 +1,13 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mvturnho", "@danielschramm"] IS_PLATFORM_COMPONENT = True @@ -18,7 +21,7 @@ CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" -def validate_id(config): +def validate_id(config: ConfigType) -> ConfigType: if CONF_CAN_ID in config: can_id = config[CONF_CAN_ID] id_ext = config[CONF_USE_EXTENDED_ID] @@ -27,7 +30,7 @@ def validate_id(config): return config -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -71,7 +74,7 @@ CAN_SPEEDS = { } -def get_rate(value): +def get_rate(value: str) -> int: match = re.match(r"(\d+)(?:K(\d+)?)?BPS", value, re.IGNORECASE) if not match: raise ValueError(f"Invalid rate format: {value}") @@ -103,7 +106,7 @@ CANBUS_SCHEMA = cv.Schema( CANBUS_SCHEMA.add_extra(validate_id) -async def setup_canbus_core_(var, config): +async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_can_id([config[CONF_CAN_ID]])) cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) @@ -134,7 +137,7 @@ async def setup_canbus_core_(var, config): ) -async def register_canbus(var, config): +async def register_canbus(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) await setup_canbus_core_(var, config) @@ -157,7 +160,12 @@ async def register_canbus(var, config): ), synchronous=True, ) -async def canbus_action_to_code(config, action_id, template_arg, args): +async def canbus_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 87f00ce507..ba0be4d3a5 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] MULTI_CONF = True @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/daly_bms/binary_sensor.py b/esphome/components/daly_bms/binary_sensor.py index 95a2ae3b44..2b6ceffff1 100644 --- a/esphome/components/daly_bms/binary_sensor.py +++ b/esphome/components/daly_bms/binary_sensor.py @@ -1,6 +1,8 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -27,13 +29,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await binary_sensor.new_binary_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_binary_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/sensor.py b/esphome/components/daly_bms/sensor.py index aa92cfa86a..3e91fb280a 100644 --- a/esphome/components/daly_bms/sensor.py +++ b/esphome/components/daly_bms/sensor.py @@ -23,6 +23,8 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -222,13 +224,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/text_sensor.py b/esphome/components/daly_bms/text_sensor.py index 9f4e2df85a..1a91081bbf 100644 --- a/esphome/components/daly_bms/text_sensor.py +++ b/esphome/components/daly_bms/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATUS +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -23,13 +25,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 3b70f947d2..91131a3ed7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -38,7 +38,8 @@ from esphome.const import ( PLATFORM_NRF52, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType WAKEUP_PINS = { @@ -174,7 +175,7 @@ def validate_config(config: ConfigType) -> ConfigType: return config -def _validate_ex1_wakeup_mode(value): +def _validate_ex1_wakeup_mode(value: str) -> str: if value == "ALL_LOW": esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) if value == "ANY_LOW": @@ -345,7 +346,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -458,7 +459,12 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( DEEP_SLEEP_ENTER_SCHEMA, synchronous=True, ) -async def deep_sleep_enter_to_code(config, action_id, template_arg, args): +async def deep_sleep_enter_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: @@ -487,7 +493,12 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), synchronous=True, ) -async def deep_sleep_action_to_code(config, action_id, template_arg, args): +async def deep_sleep_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 0e7bb976a2..a3ae3eb5af 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def ds1307_write_time_to_code(config, action_id, template_arg, args): +async def ds1307_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def ds1307_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def ds1307_read_time_to_code(config, action_id, template_arg, args): +async def ds1307_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 28c8c7fcf1..4f6355df70 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -38,7 +38,8 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.enum import StrEnum from esphome.types import ConfigType @@ -262,7 +263,7 @@ ESP_BLE_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN) @@ -360,7 +361,7 @@ async def to_code(config): # chance to call register_ble_tracker and register_client before the list is checked # and added to the global defines list. @coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_features(): +async def _add_ble_features() -> None: # Add feature-specific defines based on what's needed required_features = _get_required_features() # Sensors registered through the neutral ble_device_base path (BLEHub) need @@ -389,8 +390,11 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def esp32_ble_tracker_start_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) @@ -414,8 +418,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( synchronous=True, ) async def esp32_ble_tracker_stop_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 7686b64cb4..cd5904f501 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -48,10 +48,12 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -276,7 +278,7 @@ def _validate_spi_interface(config: ConfigType) -> ConfigType: return config -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: if CONF_MANUAL_IP in config: use_address = str(config[CONF_MANUAL_IP][CONF_STATIC_IP]) @@ -441,7 +443,7 @@ GENERIC_SCHEMA = cv.All( ) -def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): +def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) -> cv.All: return cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -517,7 +519,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate_spi(config): +def _final_validate_spi(config: ConfigType) -> None: if not CORE.is_esp32: return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: @@ -537,7 +539,7 @@ def _final_validate_spi(config): ) -def manual_ip(config): +def manual_ip(config: ConfigType) -> cg.StructInitializer: return cg.StructInitializer( ManualIP, ("static_ip", ip_address_literal(config[CONF_STATIC_IP])), @@ -548,7 +550,7 @@ def manual_ip(config): ) -def phy_register(address: int, value: int, page: int): +def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: return cg.StructInitializer( PHYRegister, ("address", address), @@ -558,7 +560,7 @@ def phy_register(address: int, value: int, page: int): @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) # Apply network priority before register_component (which emits the user's @@ -610,7 +612,7 @@ async def to_code(config): CORE.add_job(final_step) -async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, @@ -698,7 +700,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_component(name=component.name, ref=component.version) -async def _to_code_rp2040(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_rp2040(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) @@ -793,7 +795,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional Ethernet features.""" if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") @@ -845,7 +847,12 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -async def _new_pvariable_to_code(config, id_, template_arg, args): +async def _new_pvariable_to_code( + config: ConfigType, + id_: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(id_, template_arg) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index a6265b9b98..6d91c3df7c 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -16,6 +18,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -62,7 +67,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -86,7 +91,7 @@ HDC302X_HEATER_POWER_MAP = { } -def heater_power_value(value): +def heater_power_value(value: Any) -> cv.Lambda | int: """Accept enum names or raw uint16 values""" if isinstance(value, cv.Lambda): return value @@ -119,7 +124,12 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( HDC302X_HEATER_ON_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) @@ -135,7 +145,12 @@ async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): HDC302X_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 8808dc70f5..86dca77725 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -95,7 +98,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_heater_level_to_code(config, action_id, template_arg, args): +async def set_heater_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +123,12 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_heater_to_code(config, action_id, template_arg, args): +async def set_heater_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 99f2ead3bb..af1e501a6a 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -60,7 +60,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 63f7b40c23..74095d5ded 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import LD6002BComponent from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS @@ -36,7 +37,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if target_config := config.get(CONF_TARGET): diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 508d5c2bc6..a664890a86 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -129,7 +129,7 @@ BUTTON_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: for key, button_type in BUTTON_MAP.items(): if button_config := config.get(key): b = cg.new_Pvariable(button_config[CONF_ID], button_type) diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 452e38d6e3..236b049f53 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -136,7 +136,7 @@ def final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, number_type, setter, min_value, max_value, step in ( diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3da647ee2c..7f5e528b84 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -64,7 +65,7 @@ SELECT_MAP = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, select_type, setter, options in SELECT_MAP: diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 3aedaf9fdd..cceefb3837 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType from . import LD6002BComponent from .const import ( @@ -150,7 +151,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py index d27baa87fe..a414308b65 100644 --- a/esphome/components/ld6002b/switch/__init__.py +++ b/esphome/components/ld6002b/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( @@ -46,7 +47,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, switch_type, setter in ( diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py index a18d387437..0e8e2e80e7 100644 --- a/esphome/components/ld6002b/text_sensor.py +++ b/esphome/components/ld6002b/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import LD6002BComponent from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if work_mode_config := config.get(CONF_WORK_MODE): sens = await text_sensor.new_text_sensor(work_mode_config) diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index a1c197b381..6404bcf64c 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@rnauber"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(i2c.i2c_device_schema(0x43)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/m5stack_8angle/binary_sensor/__init__.py b/esphome/components/m5stack_8angle/binary_sensor/__init__.py index 22ab73e901..09398876d4 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/__init__.py +++ b/esphome/components/m5stack_8angle/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) sens = await binary_sensor.new_binary_sensor(config) cg.add(sens.set_parent(hub)) diff --git a/esphome/components/m5stack_8angle/light/__init__.py b/esphome/components/m5stack_8angle/light/__init__.py index 806ecaabf4..5c4863acf7 100644 --- a/esphome/components/m5stack_8angle/light/__init__.py +++ b/esphome/components/m5stack_8angle/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) lights = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(lights, config) diff --git a/esphome/components/m5stack_8angle/sensor/__init__.py b/esphome/components/m5stack_8angle/sensor/__init__.py index 2132eaa4c2..87d1425241 100644 --- a/esphome/components/m5stack_8angle/sensor/__init__.py +++ b/esphome/components/m5stack_8angle/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import ( CONF_M5STACK_8ANGLE_ID, @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_M5STACK_8ANGLE_ID]) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 58bd0f65dc..a98591c6bc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,8 +8,10 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -84,7 +86,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(modbus_ns.using) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -112,7 +114,9 @@ def _validate_server_address(value: Any) -> int: return address -def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): +def modbus_device_schema( + default_address: int | None, role: Literal["client", "server"] = "client" +) -> cv.Schema: hub_type = ModbusClient if role == "client" else ModbusServer address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t schema = { @@ -127,14 +131,14 @@ def modbus_device_schema(default_address, role: Literal["client", "server"] = "c def final_validate_modbus_device( name: str, *, role: Literal["server", "client"] | None = None -): - def validate_role(value): +) -> cv.Schema: + def validate_role(value: str) -> str: assert role in MODBUS_ROLES if value != role: raise cv.Invalid(f"Component {name} requires role to be {role}") return value - def validate_hub(hub_config): + def validate_hub(hub_config: ConfigType) -> ConfigType: hub_schema = {} if role is not None: hub_schema[cv.Required(CONF_ROLE)] = validate_role @@ -147,19 +151,19 @@ def final_validate_modbus_device( ) -async def register_modbus_client_device(var, config): +async def register_modbus_client_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) -async def register_modbus_server_device(var, config): +async def register_modbus_server_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) -async def register_modbus_device(var, config): +async def register_modbus_device(var: MockObj, config: ConfigType) -> None: # Remove before 2026.12.0 _LOGGER.warning( "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 4018ad81e7..ab69f5d9ae 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( @@ -31,10 +33,12 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -76,7 +80,7 @@ CONF_DEVICE_TYPES = [ ] -def _validate_txpower(value): +def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: variant = get_esp32_variant() @@ -90,7 +94,7 @@ def _validate_txpower(value): return value # Unsupported, fail later with clear error -def set_sdkconfig_options(config): +def set_sdkconfig_options(config: ConfigType) -> None: # and expose options for using SPI/UART RCPs add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) @@ -180,7 +184,7 @@ def _validate(config: ConfigType) -> ConfigType: return config -def _require_vfs_select(config): +def _require_vfs_select(config: ConfigType) -> ConfigType: """Register VFS select requirement during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: @@ -188,7 +192,7 @@ def _require_vfs_select(config): return config -def _validate_platform(config): +def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config return only_on_variant( @@ -203,7 +207,7 @@ def _validate_platform(config): )(config) -def _validate_tlv_hex(value): +def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: raise cv.Invalid("TLV must have an even number of hex characters") @@ -242,7 +246,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: full_config = fv.full_config.get() network_config = full_config.get("network", {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -274,7 +278,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -339,7 +343,12 @@ POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( POLL_PERIOD_ACTION_SCHEMA, synchronous=True, ) -async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): +async def openthread_poll_period_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 3326745846..7c5a0590d7 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor @@ -19,7 +21,9 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_USE_PCNT = "use_pcnt" @@ -42,7 +46,7 @@ SetTotalPulsesAction = pulse_counter_ns.class_( ) -def validate_internal_filter(value): +def validate_internal_filter(value: ConfigType) -> ConfigType: use_pcnt = value.get(CONF_USE_PCNT) if CORE.is_esp8266 and use_pcnt: raise cv.Invalid( @@ -63,7 +67,7 @@ def validate_internal_filter(value): return value -def validate_pulse_counter_pin(value): +def validate_pulse_counter_pin(value: Any) -> ConfigType: value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: raise cv.Invalid( @@ -72,7 +76,7 @@ def validate_pulse_counter_pin(value): return value -def validate_count_mode(value): +def validate_count_mode(value: ConfigType) -> ConfigType: rising_edge = value[CONF_RISING_EDGE] falling_edge = value[CONF_FALLING_EDGE] if rising_edge == "DISABLE" and falling_edge == "DISABLE": @@ -126,7 +130,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: use_pcnt = config.get(CONF_USE_PCNT) if CORE.is_esp32 and use_pcnt: include_builtin_idf_component("esp_driver_pcnt") @@ -157,7 +161,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_total_action_to_code(config, action_id, template_arg, args): +async def set_total_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index ab3dd2a249..9bda891efc 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor @@ -17,7 +19,9 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE +from esphome.core import CORE, ID, TimePeriodMicroseconds +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@stevebaxter", "@cstaahl", "@TrentHouliston"] @@ -37,18 +41,18 @@ FILTER_MODES = { SetTotalPulsesAction = pulse_meter_ns.class_("SetTotalPulsesAction", automation.Action) -def validate_internal_filter(value): +def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: return cv.positive_time_period_microseconds(value) -def validate_timeout(value): +def validate_timeout(value: Any) -> TimePeriodMicroseconds: value = cv.positive_time_period_microseconds(value) if value.total_minutes > 70: raise cv.Invalid("Maximum timeout is 70 minutes") return value -def validate_pulse_meter_pin(value): +def validate_pulse_meter_pin(value: Any) -> ConfigType: value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: raise cv.Invalid( @@ -81,7 +85,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -107,7 +111,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_total_action_to_code(config, action_id, template_arg, args): +async def set_total_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index dd99fcbc90..c166076e0f 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -1,6 +1,7 @@ import hashlib from pathlib import Path import re +from typing import Any from esphome import external_files, pins import esphome.codegen as cg @@ -66,7 +67,7 @@ KNOWN_FIRMWARE = { } -def parse_firmware_version(value): +def parse_firmware_version(value: str) -> tuple[int, int]: match = re.fullmatch(r"(\d+)\.(\d+)", value) if match is None: raise ValueError(f"Not a valid version number {value}") @@ -154,7 +155,7 @@ def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) -def validate_firmware(value): +def validate_firmware(value: ConfigType) -> ConfigType: config = value.copy() if CONF_URL not in config: try: @@ -167,14 +168,14 @@ def validate_firmware(value): return config -def validate_sha256(value): +def validate_sha256(value: Any) -> str: value = cv.string(value) if not re.fullmatch(r"[0-9a-fA-F]{64}", value): raise ValueError(f"Not a valid SHA256 hex string: {value}") return value -def validate_version(value): +def validate_version(value: str) -> str: parse_firmware_version(value) return value @@ -231,7 +232,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: fw_hex = get_firmware(config[CONF_FIRMWARE]) fw_major, fw_minor = parse_firmware_version(config[CONF_FIRMWARE][CONF_VERSION]) From 00cffa09a2491be8a39ffd1a62d2c6355bedc61c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 21 Aug 2026 14:05:07 -0400 Subject: [PATCH 196/470] [sendspin] Convert tests to package-style includes (#18588) --- tests/components/sendspin/common-action.yaml | 2 +- tests/components/sendspin/common-ethernet.yaml | 5 +++++ tests/components/sendspin/common-hub.yaml | 6 ++++++ tests/components/sendspin/common-media_player.yaml | 3 ++- tests/components/sendspin/common-media_source.yaml | 3 ++- tests/components/sendspin/common-sensor.yaml | 3 ++- tests/components/sendspin/common-text_sensor.yaml | 3 ++- tests/components/sendspin/common.yaml | 10 +++------- tests/components/sendspin/test-action.esp32-idf.yaml | 3 ++- .../components/sendspin/test-ethernet.esp32-idf.yaml | 11 ++--------- .../sendspin/test-media_player.esp32-idf.yaml | 3 ++- .../sendspin/test-media_source.esp32-idf.yaml | 3 ++- tests/components/sendspin/test-sensor.esp32-idf.yaml | 3 ++- .../sendspin/test-text_sensor.esp32-idf.yaml | 3 ++- tests/components/sendspin/test.esp32-idf.yaml | 3 ++- 15 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 tests/components/sendspin/common-ethernet.yaml create mode 100644 tests/components/sendspin/common-hub.yaml diff --git a/tests/components/sendspin/common-action.yaml b/tests/components/sendspin/common-action.yaml index 16f19ad7d1..1bba06ab46 100644 --- a/tests/components/sendspin/common-action.yaml +++ b/tests/components/sendspin/common-action.yaml @@ -1,6 +1,6 @@ # `sendspin.switch` action enables the controller role, so we use a standalone test packages: - base: !include common.yaml + sendspin: !include common.yaml wifi: on_connect: diff --git a/tests/components/sendspin/common-ethernet.yaml b/tests/components/sendspin/common-ethernet.yaml new file mode 100644 index 0000000000..276163cda1 --- /dev/null +++ b/tests/components/sendspin/common-ethernet.yaml @@ -0,0 +1,5 @@ +packages: + sendspin_hub: !include common-hub.yaml + +ethernet: + type: OPENETH diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml new file mode 100644 index 0000000000..7a6a9ffd4f --- /dev/null +++ b/tests/components/sendspin/common-hub.yaml @@ -0,0 +1,6 @@ +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true diff --git a/tests/components/sendspin/common-media_player.yaml b/tests/components/sendspin/common-media_player.yaml index d3792cf470..afb8b992f3 100644 --- a/tests/components/sendspin/common-media_player.yaml +++ b/tests/components/sendspin/common-media_player.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml media_player: - platform: sendspin diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 5b33a54647..1977b79c04 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml media_source: - platform: sendspin diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml index 6d9745cff9..6467e38b90 100644 --- a/tests/components/sendspin/common-sensor.yaml +++ b/tests/components/sendspin/common-sensor.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml sensor: - platform: sendspin diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index fc6a56a21a..23111e8d37 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml text_sensor: - platform: sendspin diff --git a/tests/components/sendspin/common.yaml b/tests/components/sendspin/common.yaml index 9d7da76758..980635b4e3 100644 --- a/tests/components/sendspin/common.yaml +++ b/tests/components/sendspin/common.yaml @@ -1,9 +1,5 @@ +packages: + sendspin_hub: !include common-hub.yaml + wifi: ap: - -psram: - mode: quad - -sendspin: - id: sendspin_hub_id - task_stack_in_psram: true diff --git a/tests/components/sendspin/test-action.esp32-idf.yaml b/tests/components/sendspin/test-action.esp32-idf.yaml index 70a7ee1bad..080eb59034 100644 --- a/tests/components/sendspin/test-action.esp32-idf.yaml +++ b/tests/components/sendspin/test-action.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-action.yaml +packages: + sendspin: !include common-action.yaml diff --git a/tests/components/sendspin/test-ethernet.esp32-idf.yaml b/tests/components/sendspin/test-ethernet.esp32-idf.yaml index 069e397d99..09a951d211 100644 --- a/tests/components/sendspin/test-ethernet.esp32-idf.yaml +++ b/tests/components/sendspin/test-ethernet.esp32-idf.yaml @@ -1,9 +1,2 @@ -ethernet: - type: OPENETH - -psram: - mode: quad - -sendspin: - id: sendspin_hub_id - task_stack_in_psram: true +packages: + sendspin: !include common-ethernet.yaml diff --git a/tests/components/sendspin/test-media_player.esp32-idf.yaml b/tests/components/sendspin/test-media_player.esp32-idf.yaml index cbbdb07c77..bcd4062bbe 100644 --- a/tests/components/sendspin/test-media_player.esp32-idf.yaml +++ b/tests/components/sendspin/test-media_player.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-media_player.yaml +packages: + sendspin: !include common-media_player.yaml diff --git a/tests/components/sendspin/test-media_source.esp32-idf.yaml b/tests/components/sendspin/test-media_source.esp32-idf.yaml index 47aeb2257c..faadccb06d 100644 --- a/tests/components/sendspin/test-media_source.esp32-idf.yaml +++ b/tests/components/sendspin/test-media_source.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-media_source.yaml +packages: + sendspin: !include common-media_source.yaml diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml index f9127d47bc..1646902ca3 100644 --- a/tests/components/sendspin/test-sensor.esp32-idf.yaml +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-sensor.yaml +packages: + sendspin: !include common-sensor.yaml diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml index 8998b8896e..69cf8e63fb 100644 --- a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-text_sensor.yaml +packages: + sendspin: !include common-text_sensor.yaml diff --git a/tests/components/sendspin/test.esp32-idf.yaml b/tests/components/sendspin/test.esp32-idf.yaml index dade44d145..36667f7fae 100644 --- a/tests/components/sendspin/test.esp32-idf.yaml +++ b/tests/components/sendspin/test.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml From abc9098bd833ca2186b4dc0ec59bf32d049d862d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:05:00 -0500 Subject: [PATCH 197/470] Bump bundled esphome-device-builder to 1.12.3 (#18601) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2bbe5331e5..4cde6505b3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.3 RUN \ platformio settings set enable_telemetry No \ From 11ea819bc7728d72586f34f381de3c57d1584ff5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:36:47 -0500 Subject: [PATCH 198/470] Bump aioesphomeapi from 45.12.0 to 45.13.1 (#18600) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 740a8c1a79..3362e43239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.12.0 +aioesphomeapi==45.13.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 8e9fb0f93c9c8da438dd1f301e8ef593d94ca4c2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 21 Aug 2026 23:10:47 -0500 Subject: [PATCH 199/470] [remote_transmitter] Fix repeat gap timing on LibreTiny Beken (#18585) Co-authored-by: J. Nick Koston --- .../remote_transmitter/remote_transmitter.cpp | 40 ++++++++++++------- .../remote_transmitter/remote_transmitter.h | 2 +- .../remote_transmitter/test.bk72xx-ard.yaml | 7 ++++ 3 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 tests/components/remote_transmitter/test.bk72xx-ard.yaml diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 49c711330b..31e7464314 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -81,25 +81,37 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); - this->target_time_ = 0; this->transmit_trigger_.trigger(); for (uint32_t i = 0; i < send_times; i++) { - InterruptLock lock; - for (int32_t item : this->temp_.get_data()) { - if (item > 0) { - const auto length = uint32_t(item); - this->mark_(on_time, off_time, length); - } else { - const auto length = uint32_t(-item); - this->space_(length); + { + InterruptLock lock; + // Re-anchor every iteration: timing must never span a lock boundary, as micros() can + // jump when interrupts are re-enabled between repeats (e.g. LibreTiny's Beken micros() + // discards its interrupt-lock correction, stretching the repeat gap by the lock duration) + this->target_time_ = 0; + for (int32_t item : this->temp_.get_data()) { + if (item > 0) { + const auto length = uint32_t(item); + this->mark_(on_time, off_time, length); + } else { + const auto length = uint32_t(-item); + this->space_(length); + } + App.feed_wdt(); } - App.feed_wdt(); + this->await_target_time_(); // wait for duration of last pulse + this->pin_->digital_write(false); } - this->await_target_time_(); // wait for duration of last pulse - this->pin_->digital_write(false); - if (i + 1 < send_times) - this->target_time_ += send_wait; + if (i + 1 < send_times) { + // Wait out the repeat gap with interrupts enabled: wait_time is unbounded user config + // (previously this spin ran inside the next iteration's lock, disabling interrupts for + // the whole gap). Anchoring after the lock release keeps it exact on all platforms. + const uint32_t gap_end = micros() + send_wait; + while ((int32_t) (gap_end - micros()) > 0) { + App.feed_wdt(); + } + } } this->complete_trigger_.trigger(); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index e2d33d13cc..0aa04682ba 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -72,7 +72,7 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa void space_(uint32_t usec); void await_target_time_(); - uint32_t target_time_; + uint32_t target_time_{0}; #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2a5cceddec --- /dev/null +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO26 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From 5a300e92f14ef6e2f308dd2394bc5f999fcd5b5f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:14:56 -0500 Subject: [PATCH 200/470] [wifi] Inline the trivial WiFiScanResult accessors (#18613) --- esphome/components/wifi/wifi_component.cpp | 8 -------- esphome/components/wifi/wifi_component.h | 14 +++++++------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 127eb50df1..5ed5fc9094 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2396,14 +2396,6 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { } return true; } -bool WiFiScanResult::get_matches() const { return this->matches_; } -void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; } -const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; } -uint8_t WiFiScanResult::get_channel() const { return this->channel_; } -int8_t WiFiScanResult::get_rssi() const { return this->rssi_; } -bool WiFiScanResult::get_with_auth() const { return this->with_auth_; } -bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; } - bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; } void WiFiComponent::clear_roaming_state_() { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ea043fd5c6..ff90fbe49b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -319,14 +319,14 @@ class WiFiScanResult { bool matches(const WiFiAP &config) const; - bool get_matches() const; - void set_matches(bool matches); - const bssid_t &get_bssid() const; + bool get_matches() const { return this->matches_; } + void set_matches(bool matches) { this->matches_ = matches; } + const bssid_t &get_bssid() const { return this->bssid_; } StringRef get_ssid() const { return this->ssid_.ref(); } - uint8_t get_channel() const; - int8_t get_rssi() const; - bool get_with_auth() const; - bool get_is_hidden() const; + uint8_t get_channel() const { return this->channel_; } + int8_t get_rssi() const { return this->rssi_; } + bool get_with_auth() const { return this->with_auth_; } + bool get_is_hidden() const { return this->is_hidden_; } int8_t get_priority() const { return priority_; } void set_priority(int8_t priority) { priority_ = priority; } From a30e82459f2d7fbb97d2c4861f87b2c784938c9f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:51:19 -0500 Subject: [PATCH 201/470] [deep_sleep] Reject wakeup_pin_mode at both levels on BK72xx (#18615) --- esphome/components/deep_sleep/__init__.py | 5 +++ .../deep_sleep/test_deep_sleep.py | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 91131a3ed7..dc03708645 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -163,6 +163,11 @@ def validate_config(config: ConfigType) -> ConfigType: "You need to remove the global wakeup_pin_mode and define it per pin" ) if wakeup_pins: + if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: + raise cv.Invalid( + "Specify wakeup_pin_mode either at the top level under deep_sleep " + "or under the pin entry, not both" + ) wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) elif ( isinstance(config.get(CONF_WAKEUP_PIN), list) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index f105ed5888..e68b1d17cc 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,13 @@ """Tests for the deep sleep component.""" +import pytest + +from esphome import config_validation as cv +from esphome.components import deep_sleep +from esphome.const import CONF_WAKEUP_PIN, PlatformFramework + +from ..types import SetCoreConfigCallable + def test_deep_sleep_setup(generate_main): """ @@ -83,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main): " .gpio_cause = 30000,\n" "});" ) in main_cpp + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + result = deep_sleep.validate_config(config) + + assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result + assert ( + result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" + ) From 65704e881f868546390770ec1ca75b63c97739de Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sat, 22 Aug 2026 06:52:18 +0200 Subject: [PATCH 202/470] [mitsubishi_cn105] Add Fahrenheit support (#15488) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 3 + .../mitsubishi_cn105/mitsubishi_cn105.h | 1 + .../mitsubishi_cn105_climate.cpp | 20 ++++--- .../mitsubishi_cn105_component.cpp | 7 +++ .../mitsubishi_cn105_component.h | 35 ++++++++++- esphome/components/mqtt/mqtt_climate.cpp | 3 +- .../mitsubishi_cn105_climate_tests.cpp | 60 +++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 1 + tests/components/mitsubishi_cn105/common.yaml | 1 + 9 files changed, 121 insertions(+), 10 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 450d1cd222..470b7be5fc 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL, + CONF_USE_FAHRENHEIT, ) from esphome.core import ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj @@ -71,6 +72,7 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, cv.Optional(CONF_VANE): cv.Schema( { cv.Optional(CONF_ON_STATE): automation.validate_automation({}), @@ -114,6 +116,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] ) ) + cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): cg.add_global(mitsubishi_ns.using) for conf in on_state: diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index b6b11b4820..4d3f899dee 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -83,6 +83,7 @@ class MitsubishiCN105 { return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) : !std::isnan(this->status_.target_temperature); } + bool is_temperature_encoding_b() const { return this->property_context_.use_temperature_encoding_b; } void set_power(bool power_on); void set_target_temperature(float target_temperature); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 197e1e1bb5..17ff6d34ca 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -50,7 +50,11 @@ static constexpr std::optional reverse_map_lookup(const std::arrayparent_->get_temperature_mapping().get_use_fahrenheit() ? 'F' : 'C'); +} void MitsubishiCN105Climate::setup() { this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); @@ -72,13 +76,15 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.set_supported_swing_modes(this->supported_swing_modes_); - traits.set_visual_min_temperature(16.0f); - traits.set_visual_max_temperature(31.0f); + const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit(); + traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS); + traits.set_visual_min_temperature(use_fahrenheit ? 61.0f : 16.0f); + traits.set_visual_max_temperature(use_fahrenheit ? 88.0f : 31.0f); traits.set_visual_temperature_step(1.0f); if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - traits.set_visual_current_temperature_step(0.5f); + traits.set_visual_current_temperature_step(use_fahrenheit ? 1.0f : 0.5f); } return traits; @@ -86,7 +92,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->parent_->set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(this->parent_->get_temperature_mapping().to_mitsubishi(*target_temperature)); } if (const auto mode = call.get_mode()) { @@ -139,10 +145,10 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { void MitsubishiCN105Climate::apply_values_() { const auto &status = this->parent_->status(); - this->target_temperature = status.target_temperature; + this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature); if (this->parent_->is_telemetry_polling_enabled()) { - this->current_temperature = status.room_temperature; + this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature); } if (status.power_on) { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 8e9e954645..e2a6ee05af 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -27,6 +27,13 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); } void MitsubishiCN105Component::loop() { if (this->hp_.update()) { + // Encoding A only supports whole °C values and cannot represent native °F setpoints accurately. + // See https://github.com/esphome/esphome/pull/15488#issuecomment-5268304343 + if (this->temperature_mapping_.get_use_fahrenheit() && !this->hp_.is_temperature_encoding_b()) { + ESP_LOGE(TAG, "Unit reports encoding A, which cannot accurately convert °F setpoints; disable 'use_fahrenheit'"); + this->mark_failed(); + return; + } this->notify_status_listeners_(); } } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 6461fb464b..508a15e6d5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -3,13 +3,43 @@ #include "mitsubishi_cn105.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" -#include +#include +#include #include +#include namespace esphome::mitsubishi_cn105 { +struct TemperatureMapping { + float to_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + const int fahrenheit = std::clamp(static_cast(std::round(value)), 61, 88); + return 0.5f * (fahrenheit - 28 + (fahrenheit > 68) - (fahrenheit < 68)); + } + + float from_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + if (value < 16.0f || value > 30.5f) { + return celsius_to_fahrenheit(value); + } + const int mitsubishi_half_degrees = static_cast(std::round(value * 2.0f)); + return mitsubishi_half_degrees + 29 - (mitsubishi_half_degrees >= 40) - (mitsubishi_half_degrees > 40); + } + + bool get_use_fahrenheit() const { return this->use_fahrenheit_; } + void set_use_fahrenheit(bool value) { this->use_fahrenheit_ = value; } + + protected: + bool use_fahrenheit_{false}; +}; + enum VerticalVaneMode : uint8_t { VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), @@ -60,6 +90,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + void set_use_fahrenheit(bool value) { this->temperature_mapping_.set_use_fahrenheit(value); } void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } @@ -75,6 +106,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + const TemperatureMapping &get_temperature_mapping() const { return this->temperature_mapping_; } template void add_on_status_callback(F &&callback) { this->status_callback_.add(std::forward(callback)); @@ -99,6 +131,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { } MitsubishiCN105 hp_; + TemperatureMapping temperature_mapping_; CallbackManager status_callback_; LazyCallbackManager vane_state_callback_; }; diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index d5ee4c6a9b..0e6a374f9b 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -118,8 +118,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; - // temperature units are always coerced to Celsius internally - root[MQTT_TEMPERATURE_UNIT] = "C"; + root[MQTT_TEMPERATURE_UNIT] = traits.get_temperature_unit() == TemperatureUnit::FAHRENHEIT ? "F" : "C"; // min_humidity root[MQTT_MIN_HUMIDITY] = traits.get_visual_min_humidity(); diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp index 36e0fc90b4..b91252c9fa 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -1,7 +1,67 @@ +#include +#include #include "../common.h" namespace esphome::mitsubishi_cn105::testing { +TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) { + TestableMitsubishiCN105Climate sut; + const auto mapping = TemperatureMapping(); + + for (int temperature = 16; temperature <= 31; ++temperature) { + EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature); + EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature); + } + + const auto traits = sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f); +} + +TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) { + TestableMitsubishiCN105Climate sut; + auto mapping = TemperatureMapping(); + mapping.set_use_fahrenheit(true); + sut.set_use_fahrenheit(true); + + const std::array cases{ + std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f}, + std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f}, + std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f}, + std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f}, + std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f}, + std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f}, + }; + + for (const auto &[fahrenheit, mitsubishi_celsius] : cases) { + EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius); + EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit); + } + const auto traits = sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f); +} + +TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) { + auto mapping = TemperatureMapping(); + mapping.set_use_fahrenheit(true); + + const std::array cases{ + std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f}, + std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f}, + }; + + for (const auto &[celsius, fahrenheit] : cases) { + EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit); + } +} + TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { TestableMitsubishiCN105Climate sut; diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index f542880eef..ee287d2548 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -73,6 +73,7 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; MitsubishiCN105::Status &status() { return const_cast(this->component_.status()); } + void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); } protected: MitsubishiCN105Component component_; diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index fc14724786..3f7e8c8f95 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,7 @@ mitsubishi_cn105: uart_id: uart_bus update_interval: 30s telemetry_request_min_interval: 120s + use_fahrenheit: true vane: on_state: - logger.log: From dccf55eadc6c41eaadeca24d10d7cd470ccf8bd7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 21 Aug 2026 23:53:16 -0500 Subject: [PATCH 203/470] [remote_transmitter] Use hardware PWM on rtl87xx to fix watchdog crash (#18579) --- .../components/remote_transmitter/__init__.py | 4 +- .../remote_transmitter/remote_transmitter.cpp | 3 +- .../remote_transmitter/remote_transmitter.h | 13 +- .../remote_transmitter_rtl87xx.cpp | 137 ++++++++++++++++++ .../remote_transmitter/test.rtl87xx-ard.yaml | 7 + 5 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp create mode 100644 tests/components/remote_transmitter/test.rtl87xx-ard.yaml diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index a97b925e06..9d8761ea90 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,12 +185,14 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, + "remote_transmitter_rtl87xx.cpp": { + PlatformFramework.RTL87XX_ARDUINO, + }, "remote_transmitter.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, PlatformFramework.RP2_ARDUINO, }, diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 31e7464314..67341e936f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \ + (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 0aa04682ba..94bcb74b09 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -65,14 +65,21 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) + void await_target_time_(); + uint32_t target_time_{0}; +#endif +#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \ + (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); void space_(uint32_t usec); - - void await_target_time_(); - uint32_t target_time_{0}; +#endif +#ifdef USE_RTL87XX + // Carrier frequency the PWM is currently configured for; 0 = not yet configured + uint32_t current_carrier_frequency_{0}; + void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp new file mode 100644 index 0000000000..b7078b9d69 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -0,0 +1,137 @@ +#include "remote_transmitter.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +// clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h +#if defined(USE_RTL87XX) && !defined(CLANG_TIDY) + +// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for +// type-name collisions between the two (e.g. PinMode) +#include +#include +#include + +namespace esphome::remote_transmitter { + +static const char *const TAG = "remote_transmitter"; + +// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier +// generation requires disabling interrupts for the whole frame, but this core's micros() is derived +// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and +// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and +// interrupts can stay enabled. +// +// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer: +// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which +// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the +// heap. pwmout_period_us() changes the frequency with no mode transitions. + +void RemoteTransmitterComponent::setup() { + // Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin + // management, and the pad is then never handed over to the PWM peripheral -- pwmout_init() + // must own the pin from the start. + PinInfo *info = pinInfo(this->pin_->get_pin()); + if (info == nullptr || !pinSupported(info, PIN_PWM)) { + // checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure + ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin()); + this->mark_failed(); + return; + } + auto *pwm = new pwmout_t(); + this->pwm_ = pwm; + pwmout_init(pwm, static_cast(info->gpio)); +#if LT_RTL8720C + // only the AmebaZ2 SDK's pwmout_s reports init success + if (!pwm->is_init) { + ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin()); + delete pwm; + this->pwm_ = nullptr; + this->mark_failed(); + return; + } +#endif + pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission + pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f); +} + +void RemoteTransmitterComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Remote Transmitter:\n" + " Carrier Duty: %u%%", + this->carrier_duty_percent_); + LOG_PIN(" Pin: ", this->pin_); +} + +void RemoteTransmitterComponent::await_target_time_() { + const uint32_t current_time = micros(); + if (this->target_time_ == 0) { + this->target_time_ = current_time; + } else { + while ((int32_t) (this->target_time_ - micros()) > 0) { + } + } +} + +void RemoteTransmitterComponent::digital_write(bool value) { + if (this->pwm_ == nullptr) + return; + pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); +} + +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + auto *pwm = static_cast(this->pwm_); + if (pwm == nullptr) { + ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + return; + } + ESP_LOGD(TAG, "Sending remote code"); + const uint32_t carrier_frequency = this->temp_.get_carrier_frequency(); + // unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks + float mark_duty = + (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f; + float space_duty = 0.0f; + if (this->pin_->is_inverted()) { + mark_duty = 1.0f - mark_duty; + space_duty = 1.0f; + } + if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) { + // round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period + const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); + pwmout_period_us(pwm, period); + this->current_carrier_frequency_ = carrier_frequency; + } + this->transmit_trigger_.trigger(); + const UBaseType_t saved_priority = uxTaskPriorityGet(nullptr); + for (uint32_t i = 0; i < send_times; i++) { + // Boost task priority for the frame only, so WiFi/lwIP tasks can't preempt mid-frame and + // merge adjacent marks. Interrupts stay enabled: micros() needs the FreeRTOS tick, and + // ISR latency is within receiver tolerance. + vTaskPrioritySet(nullptr, configMAX_PRIORITIES - 1); + // Re-anchor every iteration: a late exit from the normal-priority gap wait must not + // leave the schedule behind micros(), which would compress the next frame's leading items + this->target_time_ = 0; + for (int32_t item : this->temp_.get_data()) { + const bool is_mark = item > 0; + this->await_target_time_(); + pwmout_write(pwm, is_mark ? mark_duty : space_duty); + this->target_time_ += is_mark ? uint32_t(item) : uint32_t(-item); + App.feed_wdt(); + } + this->await_target_time_(); // wait for duration of last pulse + pwmout_write(pwm, space_duty); + vTaskPrioritySet(nullptr, saved_priority); + if (i + 1 < send_times) { + // The repeat gap is user-configurable and unbounded, so wait it out at normal + // priority, feeding the watchdog + const uint32_t gap_end = micros() + send_wait; + while ((int32_t) (gap_end - micros()) > 0) { + App.feed_wdt(); + } + } + } + this->complete_trigger_.trigger(); +} + +} // namespace esphome::remote_transmitter + +#endif // USE_RTL87XX && !CLANG_TIDY diff --git a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..769adbdf5c --- /dev/null +++ b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO12 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From ef1d77885dd5a7f1beef4e3d34e22e26d5661fa1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:04:08 -0500 Subject: [PATCH 204/470] [captive_portal] Show each network once in the scan list (#17847) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Bluetooth Devices Bot --- .../captive_portal/captive_portal.cpp | 11 +- esphome/components/captive_portal/scan_list.h | 28 ++++ esphome/components/wifi/wifi_component.h | 1 + tests/components/captive_portal/__init__.py | 10 ++ .../captive_portal/scan_list_test.cpp | 130 ++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 esphome/components/captive_portal/scan_list.h create mode 100644 tests/components/captive_portal/__init__.py create mode 100644 tests/components/captive_portal/scan_list_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 704a61d4de..ffd121499b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,6 +6,7 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" +#include "scan_list.h" namespace esphome::captive_portal { @@ -33,8 +34,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { // Invariant: only bounded in-memory work under the lock; the network send // happens later in request->send() wifi::ScanResultsLock lock(wifi::global_wifi_component); - for (const auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) + const auto &results = wifi::global_wifi_component->get_scan_result(); + for (const auto &scan : results) { + bool with_auth = false; + if (!should_show_scan_entry(results, scan, with_auth)) continue; json_escape_into_buffer(escaped_ssid, scan.get_ssid()); @@ -44,10 +47,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->print(ESPHOME_F("\",\"rssi\":")); stream->print(scan.get_rssi()); stream->print(ESPHOME_F(",\"lock\":")); - stream->print(scan.get_with_auth()); + stream->print(with_auth); stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), with_auth); #endif } } diff --git a/esphome/components/captive_portal/scan_list.h b/esphome/components/captive_portal/scan_list.h new file mode 100644 index 0000000000..d24a88a670 --- /dev/null +++ b/esphome/components/captive_portal/scan_list.h @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace esphome::captive_portal { + +// A scan lists every BSSID, so one SSID can appear several times. Returns true for +// the strongest entry per SSID (earliest on ties), never for hidden entries. scan +// must be an element of results. with_auth is written only when returning true and +// is set if any entry with that SSID needs a key. Templated for host tests. +template +bool should_show_scan_entry(const Results &results, const Entry &scan, bool &with_auth) { + if (scan.get_is_hidden()) + return false; + const int8_t rssi = scan.get_rssi(); + bool any_auth = false; + for (const auto &other : results) { + if (other.get_is_hidden() || !other.ssid_equals(scan)) + continue; + // Same array, so address order is index order. scan fails both checks against itself. + if (other.get_rssi() > rssi || (other.get_rssi() == rssi && &other < &scan)) + return false; + any_auth |= other.get_with_auth(); + } + with_auth = any_auth; + return true; +} + +} // namespace esphome::captive_portal diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ff90fbe49b..c54fbc004b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -327,6 +327,7 @@ class WiFiScanResult { int8_t get_rssi() const { return this->rssi_; } bool get_with_auth() const { return this->with_auth_; } bool get_is_hidden() const { return this->is_hidden_; } + bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; } int8_t get_priority() const { return priority_; } void set_priority(int8_t priority) { priority_ = priority; } diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py new file mode 100644 index 0000000000..1ac0704a59 --- /dev/null +++ b/tests/components/captive_portal/__init__.py @@ -0,0 +1,10 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The scan list helper is header-only and needs none of the component's real + # dependencies. Pulling them in breaks the host build: web_server_base + # includes ESPAsyncWebServer.h and ota.web_server includes md5/md5.h, neither + # of which exists there. + manifest.dependencies = [] + manifest.auto_load = [] diff --git a/tests/components/captive_portal/scan_list_test.cpp b/tests/components/captive_portal/scan_list_test.cpp new file mode 100644 index 0000000000..f67581dc0b --- /dev/null +++ b/tests/components/captive_portal/scan_list_test.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include +#include + +#include "esphome/components/captive_portal/scan_list.h" + +namespace esphome::captive_portal::testing { + +namespace { + +// Stand-in for wifi::WiFiScanResult, which does not compile on the host. +struct Entry { + std::string ssid; + int8_t rssi; + bool with_auth{true}; + bool is_hidden{false}; + + // Compares length and bytes like CompactString does, so an embedded NUL counts. + bool ssid_equals(const Entry &other) const { return this->ssid == other.ssid; } + int8_t get_rssi() const { return this->rssi; } + bool get_with_auth() const { return this->with_auth; } + bool get_is_hidden() const { return this->is_hidden; } +}; + +// One row as the portal would emit it. +struct Row { + std::string ssid; + int8_t rssi; + bool lock; + + bool operator==(const Row &rhs) const { return ssid == rhs.ssid && rssi == rhs.rssi && lock == rhs.lock; } +}; + +// Walk the results the way handle_config does and collect the rows that survive. +std::vector rows(const std::vector &results) { + std::vector out; + for (size_t i = 0; i < results.size(); i++) { + bool with_auth = false; + if (!should_show_scan_entry(results, results[i], with_auth)) + continue; + out.push_back({results[i].ssid, results[i].rssi, with_auth}); + } + return out; +} + +} // namespace + +TEST(ScanList, SingleEntryShown) { + std::vector results = {{"Home", -60}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}})); +} + +TEST(ScanList, DistinctSsidsAllShownInOrder) { + std::vector results = {{"Home", -60}, {"Guest", -70}, {"Cafe", -40}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}, {"Guest", -70, true}, {"Cafe", -40, true}})); +} + +// Results are ordered by connection preference, not RSSI, so the strongest entry +// can sit anywhere in the list. +TEST(ScanList, SameSsidKeepsStrongest) { + std::vector results = {{"Home", -70}, {"Home", -50}, {"Home", -60}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -50, true}})); +} + +TEST(ScanList, EqualRssiKeepsFirst) { + std::vector results = {{"Home", -60}, {"Home", -60}, {"Home", -60}}; + bool with_auth = false; + EXPECT_TRUE(should_show_scan_entry(results, results[0], with_auth)); + EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth)); + EXPECT_FALSE(should_show_scan_entry(results, results[2], with_auth)); + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}})); +} + +// with_auth is an out-parameter that must only be written for a shown entry. +TEST(ScanList, WithAuthUntouchedWhenNotShown) { + std::vector results = {{"Home", -50, false}, {"Home", -70, true}}; + bool with_auth = false; + EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth)); + EXPECT_FALSE(with_auth); +} + +TEST(ScanList, DuplicatesInterleavedWithOtherNetworks) { + std::vector results = {{"Home", -70}, {"Guest", -55}, {"Home", -50}, {"Guest", -65}}; + EXPECT_EQ(rows(results), (std::vector{{"Guest", -55, true}, {"Home", -50, true}})); +} + +// Hidden networks scan with an empty SSID. They are never listed and do not +// collapse into each other or into anything else. +TEST(ScanList, HiddenEntriesNeverShown) { + std::vector results = {{"", -40, true, true}, {"Home", -70}, {"", -30, true, true}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -70, true}})); +} + +// On ESP8266 the hidden flag comes from the driver alongside a real SSID, so a +// hidden access point can share its name with a visible one. It must not +// outrank that visible entry and leave the network unlisted. +TEST(ScanList, HiddenEntryDoesNotSuppressVisibleSameSsid) { + std::vector results = {{"Home", -40, true, true}, {"Home", -70}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -70, true}})); +} + +// An open access point and a secured one sharing an SSID collapse to one row that +// still asks for a password, whichever of them is strongest. +TEST(ScanList, LockSetWhenAnyEntryRequiresAuth) { + std::vector open_stronger = {{"Home", -50, false}, {"Home", -70, true}}; + EXPECT_EQ(rows(open_stronger), (std::vector{{"Home", -50, true}})); + + std::vector secured_stronger = {{"Home", -70, false}, {"Home", -50, true}}; + EXPECT_EQ(rows(secured_stronger), (std::vector{{"Home", -50, true}})); +} + +TEST(ScanList, LockClearWhenEveryEntryIsOpen) { + std::vector results = {{"Cafe", -60, false}, {"Cafe", -50, false}}; + EXPECT_EQ(rows(results), (std::vector{{"Cafe", -50, false}})); +} + +// The auth flag of an unrelated network must not leak into another SSID's row. +TEST(ScanList, LockIsPerSsid) { + std::vector results = {{"Cafe", -60, false}, {"Home", -50, true}}; + EXPECT_EQ(rows(results), (std::vector{{"Cafe", -60, false}, {"Home", -50, true}})); +} + +TEST(ScanList, EmptyListShowsNothing) { + std::vector results; + EXPECT_TRUE(rows(results).empty()); +} + +} // namespace esphome::captive_portal::testing From ea10f94376d967f2099701abd12902efdc9e7cf8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:31:47 +1200 Subject: [PATCH 205/470] [core] Add type annotations to component Python (10/11) (#18347) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/adc/__init__.py | 5 ++- esphome/components/adc/sensor.py | 6 +-- esphome/components/api/__init__.py | 36 ++++++++++++----- esphome/components/button/__init__.py | 20 ++++++---- esphome/components/climate/__init__.py | 29 ++++++++++---- esphome/components/cover/__init__.py | 40 ++++++++++++++----- .../components/dashboard_import/__init__.py | 10 +++-- esphome/components/debug/__init__.py | 3 +- esphome/components/debug/sensor.py | 3 +- esphome/components/debug/text_sensor.py | 3 +- esphome/components/esp8266/__init__.py | 18 +++++---- esphome/components/esp8266/gpio.py | 15 ++++--- esphome/components/file/image.py | 17 ++++---- esphome/components/globals/__init__.py | 12 ++++-- .../components/gpio/binary_sensor/__init__.py | 5 ++- esphome/components/gpio/one_wire/__init__.py | 3 +- esphome/components/gpio/output/__init__.py | 3 +- esphome/components/gpio/switch/__init__.py | 3 +- esphome/components/homeassistant/__init__.py | 12 ++++-- .../homeassistant/binary_sensor/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/sensor/__init__.py | 3 +- .../homeassistant/switch/__init__.py | 3 +- .../homeassistant/text_sensor/__init__.py | 3 +- .../components/homeassistant/time/__init__.py | 3 +- esphome/components/host/__init__.py | 5 ++- esphome/components/host/gpio.py | 9 +++-- esphome/components/host/time/__init__.py | 3 +- esphome/components/i2c/__init__.py | 32 ++++++++------- esphome/components/lock/__init__.py | 34 +++++++++++----- 30 files changed, 230 insertions(+), 114 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 1c50b6b81b..5c763a4f4c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( @@ -16,6 +18,7 @@ from esphome.components.esp32 import ( import esphome.config_validation as cv from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -225,7 +228,7 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { } -def validate_adc_pin(value): +def validate_adc_pin(value: Any) -> ConfigType | str: if str(value).upper() == "VCC": if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index b2a4382a21..5d1031825e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto": raise cv.Invalid("Automatic attenuation cannot be used when raw output is set") @@ -120,7 +120,7 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" -def _overlay_io_channels(): +def _overlay_io_channels() -> str: channel_count = CORE.data[CONF_ADC_CHANNEL_ID] entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) return f""" @@ -132,7 +132,7 @@ def _overlay_io_channels(): """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 912d580a0f..0dc4b905bf 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,5 +1,6 @@ import base64 import logging +from typing import Any from esphome import automation from esphome.automation import Condition @@ -129,7 +130,7 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType: return config -def validate_encryption_key(value): +def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: decoded = base64.b64decode(value, validate=True) @@ -217,7 +218,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType: return config -def _validate_supports_response(value): +def _validate_supports_response(value: Any) -> str: """Validate supports_response after auto-detection has set the value.""" return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) @@ -256,7 +257,7 @@ ENCRYPTION_SCHEMA = cv.Schema( ) -def _encryption_schema(config): +def _encryption_schema(config: ConfigType | None) -> ConfigType: if config is None: config = {} return ENCRYPTION_SCHEMA(config) @@ -393,7 +394,7 @@ async def to_code(config: ConfigType) -> None: if actions := config.get(CONF_ACTIONS, []): # Collect all triggers first, then register all at once with initializer_list - triggers: list[cg.Pvariable] = [] + triggers: list[cg.MockObj] = [] for conf in actions: func_args: list[tuple[MockObj, str]] = [] service_template_args: list[MockObj] = [] # User service argument types @@ -581,7 +582,7 @@ async def homeassistant_service_to_code( action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) @@ -647,7 +648,7 @@ async def homeassistant_service_to_code( return var -def validate_homeassistant_event(value): +def validate_homeassistant_event(value: Any) -> str: value = cv.string(value) if not value.startswith("esphome."): raise cv.Invalid( @@ -676,7 +677,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( HOMEASSISTANT_EVENT_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_event_to_code(config, action_id, template_arg, args): +async def homeassistant_event_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -724,7 +730,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): +async def homeassistant_tag_scanned_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -740,7 +751,7 @@ CONF_SUCCESS = "success" CONF_ERROR_MESSAGE = "error_message" -def _validate_api_respond_data(config): +def _validate_api_respond_data(config: ConfigType) -> ConfigType: """Set flag during validation so AUTO_LOAD can include json component.""" if CONF_DATA in config: CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True @@ -824,7 +835,12 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( @automation.register_condition( "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA ) -async def api_connected_to_code(config, condition_id, template_arg, args): +async def api_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) cg.add(var.set_state_subscription_only(templ)) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index a4245f43e6..ee24002b8a 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -88,7 +89,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("button") -async def setup_button_core_(var, config): +async def setup_button_core_(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) setup_device_class(config) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_button(var, config): +async def register_button(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("button", config) @@ -109,7 +110,7 @@ async def register_button(var, config): await setup_button_core_(var, config) -async def new_button(config, *args): +async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_button(var, config) return var @@ -125,11 +126,16 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( @automation.register_action( "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True ) -async def button_press_to_code(config, action_id, template_arg, args): +async def button_press_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(button_ns.using) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index fe050fca22..80dd913fba 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -48,13 +50,19 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import ( + LambdaExpression, + MockObj, + MockObjClass, + TemplateArgsType, +) +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -132,7 +140,7 @@ VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema( ) -def visual_temperature_step(value): +def visual_temperature_step(value: Any) -> ConfigType: # Allow defining target/current temperature steps separately if isinstance(value, dict): return VISUAL_TEMPERATURE_STEP_SCHEMA(value) @@ -273,7 +281,7 @@ def climate_schema( @setup_entity("climate") -async def setup_climate_core_(var, config): +async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") @@ -443,7 +451,7 @@ async def setup_climate_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_climate(var, config): +async def register_climate(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("climate", config) @@ -451,7 +459,7 @@ async def register_climate(var, config): await setup_climate_core_(var, config) -async def new_climate(config, *args): +async def new_climate(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_climate(var, config) return var @@ -485,7 +493,12 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( CLIMATE_CONTROL_ACTION_SCHEMA, synchronous=True, ) -async def climate_control_to_code(config, action_id, template_arg, args): +async def climate_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) # All configured fields are folded into a single stateless lambda whose @@ -549,5 +562,5 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 7639e15334..011b2c2f04 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -46,7 +46,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -162,7 +162,7 @@ _COVER_SCHEMA = ( _COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) -def _validate_mqtt_state_topics(config): +def _validate_mqtt_state_topics(config: ConfigType) -> ConfigType: if config.get(CONF_MQTT_JSON_STATE_PAYLOAD): if CONF_POSITION_STATE_TOPIC in config: raise cv.Invalid( @@ -201,7 +201,7 @@ def cover_schema( @setup_entity("cover") -async def setup_cover_core_(var, config): +async def setup_cover_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if CONF_ON_OPEN in config: @@ -235,7 +235,7 @@ async def setup_cover_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_cover(var, config): +async def register_cover(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("cover", config) @@ -243,7 +243,7 @@ async def register_cover(var, config): await setup_cover_core_(var, config) -async def new_cover(config, *args): +async def new_cover(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_cover(var, config) return var @@ -259,7 +259,12 @@ COVER_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_open_to_code(config, action_id, template_arg, args): +async def cover_open_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -267,7 +272,12 @@ async def cover_open_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_close_to_code(config, action_id, template_arg, args): +async def cover_close_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -275,7 +285,12 @@ async def cover_close_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_stop_to_code(config, action_id, template_arg, args): +async def cover_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -283,7 +298,12 @@ async def cover_stop_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_toggle_to_code(config, action_id, template_arg, args): +async def cover_toggle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -421,5 +441,5 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(cover_ns.using) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 31559a514c..c27669d77e 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -2,6 +2,7 @@ import base64 from pathlib import Path import re import secrets +from typing import Any import requests from ruamel.yaml import YAML @@ -13,6 +14,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.types import ConfigType from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -23,14 +25,14 @@ DEPENDENCIES = ["api"] CODEOWNERS = ["@esphome/core"] -def validate_import_url(value): +def validate_import_url(value: Any) -> str: value = cv.string_strict(value) value = cv.Length(max=255)(value) validate_source_shorthand(value) return value -def validate_full_url(config): +def validate_full_url(config: ConfigType) -> ConfigType: if not config[CONF_IMPORT_FULL_CONFIG]: return config source = validate_source_shorthand(config[CONF_PACKAGE_IMPORT_URL]) @@ -55,7 +57,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_ESPHOME] if CONF_PROJECT not in full_config: raise cv.Invalid( @@ -73,7 +75,7 @@ wifi: """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_DASHBOARD_IMPORT") url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 3e94d04f21..a889d13329 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CORE.using_zephyr: zephyr_add_prj_conf("HWINFO", True) # gdb thread support diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index a018ce5c3b..72e2efebc2 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if free_conf := config.get(CONF_FREE): diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index c69b8d9461..9d4fcc1b42 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ICON_CHIP, ICON_RESTART, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if CONF_DEVICE in config: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 2161a902cb..3dd9750c6f 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path import platform import re import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -31,6 +32,7 @@ from esphome.core import ( from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import IS_MACOS, copy_file_if_changed from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -88,7 +90,7 @@ def lambdas_use_scanf_float(config: ConfigType) -> bool: return False -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -102,7 +104,7 @@ def set_core_data(config): return config -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built ESP8266 firmware. Used by device-builder (esphome/device-builder), via @@ -157,7 +159,7 @@ ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1) -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(3, 1, 2), "https://github.com/esp8266/Arduino.git"), @@ -200,7 +202,7 @@ def _arduino_check_versions(value): return value -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: try: # if platform version is a valid version constraint, prefix the default package cv.platformio_version_constraint(value) @@ -275,7 +277,7 @@ def check_rosetta() -> None: @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(esp8266_ns.setup_preferences()) cg.add_platformio_option("lib_ldf_mode", "off") @@ -504,7 +506,7 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config, addr): +def _decode_pc(config: ConfigType, addr: str) -> None: from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -525,7 +527,7 @@ def _decode_pc(config, addr): _LOGGER.warning("Decoded %s", translation) -def _parse_register(config, regex, line): +def _parse_register(config: ConfigType, regex: re.Pattern[str], line: str) -> None: match = regex.match(line) if match is not None: _decode_pc(config, match.group(1)) @@ -549,7 +551,7 @@ STACKTRACE_BAD_ALLOC_RE = re.compile( STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") -def process_stacktrace(config, line, backtrace_state): +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: line = line.strip() # ESP8266 Exception type match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line) diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 64be4a6495..356af6e006 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -18,6 +19,8 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, esp8266_ns @@ -27,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) ESP8266GPIOPin = esp8266_ns.class_("ESP8266GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_ESP8266][KEY_BOARD] board_pins = boards.ESP8266_BOARD_PINS.get(board, {}) @@ -42,7 +45,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -69,7 +72,7 @@ _ESP_SDIO_PINS = { } -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) if value < 0 or value > 17: raise cv.Invalid(f"ESP8266: Invalid pin number: {value}") @@ -86,7 +89,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] @@ -160,7 +163,7 @@ class PinInitialState: @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP8266, ESP8266_PIN_SCHEMA) -async def esp8266_pin_to_code(config): +async def esp8266_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] mode = config[CONF_MODE] @@ -192,7 +195,7 @@ async def esp8266_pin_to_code(config): @coroutine_with_priority(CoroPriority.WORKAROUNDS) -async def add_pin_initial_states_array(): +async def add_pin_initial_states_array() -> None: # Add includes at the very end, so that they override everything initial_states: list[PinInitialState] = CORE.data[KEY_ESP8266][ KEY_PIN_INITIAL_STATES diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index feced063d0..7cef7c754a 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -5,6 +5,7 @@ import io import logging from pathlib import Path import re +from typing import Any from PIL import Image, UnidentifiedImageError @@ -75,12 +76,12 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value): +def local_path(value: str | ConfigType) -> str: value = value[CONF_PATH] if isinstance(value, dict) else value return str(CORE.relative_config_path(value)) -def download_file(url, path): +def download_file(url: str, path: Path) -> str: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) @@ -98,7 +99,7 @@ def download_gh_svg(value: str | ConfigType, source: str) -> str: return download_file(url, path) -def download_image(value): +def download_image(value: str | ConfigType) -> str: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -146,7 +147,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value): +def validate_file_shorthand(value: Any) -> str: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -163,8 +164,8 @@ LOCAL_SCHEMA = cv.All( ) -def mdi_schema(source): - def validate_mdi(value): +def mdi_schema(source: str) -> cv.All: + def validate_mdi(value: ConfigType) -> str: return download_gh_svg(value, source) return cv.All( @@ -259,7 +260,9 @@ async def new_image(config: ConfigType) -> MockObj: return var -async def write_image(config, all_frames=False): +async def write_image( + config: ConfigType, all_frames: bool = False +) -> tuple[MockObj, int, int, MockObj, MockObj, int]: path = Path(config[CONF_FILE]) if not path.is_file(): raise core.EsphomeError(f"Could not load image file {path}") diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 46725fe6dd..bd6bc5f783 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,7 +8,8 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -62,7 +63,7 @@ CONFIG_SCHEMA = _globals_schema # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] @@ -104,7 +105,12 @@ async def to_code(config): ), synchronous=True, ) -async def globals_set_to_code(config, action_id, template_arg, args): +async def globals_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 703806670c..7cc16eb5b2 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_PIN, ) from esphome.core import CORE +from esphome.types import ConfigType from .. import gpio_ns @@ -68,7 +69,7 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: return @@ -124,7 +125,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index e2bb94dd66..feb8b53dff 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index 786e04bac0..ab242c643f 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 9462cd0161..2e0b0969bc 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_INTERLOCK, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 7b23775b47..1b66842f1e 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,13 +1,19 @@ +from collections.abc import Callable, Iterable + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] homeassistant_ns = cg.esphome_ns.namespace("homeassistant") -def validate_entity_domain(platform, supported_domains): - def validator(config): +def validate_entity_domain( + platform: str, supported_domains: Iterable[str] +) -> Callable[[ConfigType], ConfigType]: + def validator(config: ConfigType) -> ConfigType: domain = config[CONF_ENTITY_ID].split(".", 1)[0] if domain not in supported_domains: raise cv.Invalid( @@ -34,7 +40,7 @@ HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( ) -def setup_home_assistant_entity(var, config): +def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index a943368dd7..6ea17b6831 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 8f760772c3..ab1389e13a 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await number.new_number( config, diff --git a/esphome/components/homeassistant/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index 6437476827..abee957fda 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index c299a731f2..55854cd659 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/homeassistant/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index b59f9d23df..265250c695 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 05ca86a26e..146b8278ea 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TIMEZONE +from esphome.types import ConfigType from .. import homeassistant_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await time_.register_time(var, config) await cg.register_component(var, config) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index b6a3b8b615..c5846f5406 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.platformio.toolchain import copy_ccache_script +from esphome.types import ConfigType from .const import KEY_HOST @@ -22,7 +23,7 @@ AUTO_LOAD = ["network", "preferences"] IS_TARGET_PLATFORM = True -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_HOST] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_HOST CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "host" @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_HOST") cg.add_define("USE_NATIVE_64BIT_TIME") # The prefs file finds stored preferences by key, so key migration is possible diff --git a/esphome/components/host/gpio.py b/esphome/components/host/gpio.py index fcfb0b6c54..e39d35d077 100644 --- a/esphome/components/host/gpio.py +++ b/esphome/components/host/gpio.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -14,6 +15,8 @@ from esphome.const import ( CONF_PULLDOWN, CONF_PULLUP, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from .const import host_ns @@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) HostGPIOPin = host_ns.class_("HostGPIOPin", cg.InternalGPIOPin) -def _translate_pin(value): +def _translate_pin(value: Any) -> int | str: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -41,7 +44,7 @@ def _translate_pin(value): return value -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int | str: return _translate_pin(value) @@ -53,7 +56,7 @@ HOST_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register("host", HOST_PIN_SCHEMA) -async def host_pin_to_code(config): +async def host_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/host/time/__init__.py b/esphome/components/host/time/__init__.py index d9a2f1207c..6eb0cf954d 100644 --- a/esphome/components/host/time/__init__.py +++ b/esphome/components/host/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -14,7 +15,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await time_.register_time(var, config) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 94aad4d019..b053125446 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,6 +1,7 @@ import logging import re import sys +from typing import Any from esphome import pins import esphome.codegen as cg @@ -52,9 +53,10 @@ from esphome.const import ( PLATFORM_RP2, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] @@ -96,13 +98,13 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True -def validate_device(value): +def validate_device(value: str) -> str: 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): +def _bus_declare_type(value: Any) -> ID: if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: @@ -114,7 +116,7 @@ def _bus_declare_type(value): raise NotImplementedError -def _rp2040_i2c_controller(pin): +def _rp2040_i2c_controller(pin: int) -> int: """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): @@ -125,7 +127,7 @@ def _rp2040_i2c_controller(pin): return (pin // 2) % 2 -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) @@ -142,7 +144,7 @@ def validate_config(config): return config -def validate_host_config(config): +def validate_host_config(config: ConfigType) -> ConfigType: if CORE.is_host: # Host I2C is currently only supported on Linux if not sys.platform.lower().startswith("linux"): @@ -229,7 +231,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") @@ -281,7 +283,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.BUS) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") if CORE.is_esp32: @@ -358,7 +360,7 @@ async def to_code(config): cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) -def i2c_device_schema(default_address): +def i2c_device_schema(default_address: int | None) -> cv.Schema: """Create a schema for a i2c device. :param default_address: The default address of the i2c device, can be None to represent @@ -375,7 +377,7 @@ def i2c_device_schema(default_address): return cv.Schema(schema) -async def register_i2c_device(var, config): +async def register_i2c_device(var: MockObj, config: ConfigType) -> None: """Register an i2c device with the given config. Sets the i2c bus to use and the i2c address. @@ -390,11 +392,11 @@ async def register_i2c_device(var, config): def final_validate_device_schema( name: str, *, - min_frequency: cv.frequency = None, - max_frequency: cv.frequency = None, - min_timeout: cv.time_period = None, - max_timeout: cv.time_period = None, -): + min_frequency: Any = None, + max_frequency: Any = None, + min_timeout: Any = None, + max_timeout: Any = None, +) -> cv.Schema: hub_schema = {} if (min_frequency is not None) and (max_frequency is not None): hub_schema[cv.Required(CONF_FREQUENCY)] = cv.Range( diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 0a8ad58bc2..a4a7b5237d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,13 +12,14 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -102,7 +103,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("lock") -async def _setup_lock_core(var, config): +async def _setup_lock_core(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id := config.get(CONF_MQTT_ID): @@ -113,7 +114,7 @@ async def _setup_lock_core(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_lock(var, config): +async def register_lock(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("lock", config) @@ -121,7 +122,7 @@ async def register_lock(var, config): await _setup_lock_core(var, config) -async def new_lock(config, *args): +async def new_lock(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_lock(var, config) return var @@ -143,23 +144,38 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True ) -async def lock_action_to_code(config, action_id, template_arg, args): +async def lock_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code(config, condition_id, template_arg, args): +async def lock_is_on_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code(config, condition_id, template_arg, args): +async def lock_is_off_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(lock_ns.using) From 74fc2e367abb874d74c436d9961d7ff3d9981a11 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:42:53 +0000 Subject: [PATCH 206/470] Bump bundled esphome-device-builder to 1.12.4 (#18651) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4cde6505b3..9f27d51059 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.4 RUN \ platformio settings set enable_telemetry No \ From d119ad6c6078fd8fa3d2191c4d5e2b91fbc7a38e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sat, 22 Aug 2026 17:47:46 +0200 Subject: [PATCH 207/470] [usb_uart] Extract non-final USBUartChannelBase from USBUartChannel (#17472) Co-authored-by: p1ngb4ck --- esphome/components/usb_uart/ch34x.cpp | 2 +- esphome/components/usb_uart/cp210x.cpp | 2 +- esphome/components/usb_uart/ft23xx.cpp | 6 +-- esphome/components/usb_uart/pl2303.cpp | 2 +- esphome/components/usb_uart/usb_uart.cpp | 20 ++++---- esphome/components/usb_uart/usb_uart.h | 65 ++++++++++++++---------- 6 files changed, 55 insertions(+), 42 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index abfed74f94..00c5e0b069 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -95,7 +95,7 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { uint8_t cmd = 0xA1 + channel->index_; if (channel->index_ >= 2) diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 2722ec8555..5551abe1a1 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -97,7 +97,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). if (reload) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 79aa107d72..fcebf0fbd9 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -270,7 +270,7 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { +void USBUartTypeFT23XX::start_input(USBUartChannelBase *channel) { if (!channel->initialised_.load()) return; @@ -336,12 +336,12 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { } } -void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannelBase *channel) { ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); channel->input_buffer_.clear(); } -bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios // path only re-applies baud + line properties and does not re-assert DTR/RTS. diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index c56f43f75a..a9f7348331 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -226,7 +226,7 @@ static const Pl2303InitStep PL2303_INIT[] = { }; static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); -bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index c289625f1a..cf66e4c369 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -136,7 +136,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { } return len; } -void USBUartChannel::write_array(const uint8_t *data, size_t len) { +void USBUartChannelBase::write_array(const uint8_t *data, size_t len) { if (!this->initialised_.load()) { ESP_LOGD(TAG, "Channel not initialised - write ignored"); return; @@ -170,7 +170,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { this->parent_->start_output(this); } -uart::UARTFlushResult USBUartChannel::flush() { +uart::UARTFlushResult USBUartChannelBase::flush() { // Spin until the output queue is drained and the last USB transfer completes. // Safe to call from the main loop only. // The flush_timeout_ms_ timeout guards against a device that stops responding mid-flush; @@ -186,14 +186,14 @@ uart::UARTFlushResult USBUartChannel::flush() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } -bool USBUartChannel::peek_byte(uint8_t *data) { +bool USBUartChannelBase::peek_byte(uint8_t *data) { if (this->input_buffer_.is_empty()) { return false; } *data = this->input_buffer_.peek(); return true; } -bool USBUartChannel::read_array(uint8_t *data, size_t len) { +bool USBUartChannelBase::read_array(uint8_t *data, size_t len) { if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - read ignored"); return false; @@ -277,7 +277,7 @@ void USBUartComponent::dump_config() { YESNO(channel->dummy_receiver_)); } } -void USBUartComponent::start_input(USBUartChannel *channel) { +void USBUartComponent::start_input(USBUartChannelBase *channel) { if (!channel->initialised_.load()) return; // THREAD CONTEXT: Called from both USB task and main loop threads @@ -346,7 +346,7 @@ void USBUartComponent::start_input(USBUartChannel *channel) { } } -void USBUartComponent::start_output(USBUartChannel *channel) { +void USBUartComponent::start_output(USBUartChannelBase *channel) { // THREAD CONTEXT: Called from both main loop and USB task threads. // The output_queue_ is a lock-free SPSC queue, so pop() is safe from either thread. // The output_started_ atomic flag is claimed via compare_exchange to guarantee that @@ -491,7 +491,7 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; @@ -537,7 +537,7 @@ void USBUartComponent::enable_channels() { this->start_config_(false); } -void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { +void USBUartComponent::apply_channel_settings(USBUartChannelBase *channel) { if (this->cfg_active_) { // A config sequence is already running. Defer this reload until it finishes to preserve // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an @@ -620,7 +620,7 @@ bool USBUartComponent::run_config_machine_() { this->cfg_ok_ = true; } - USBUartChannel *channel = + USBUartChannelBase *channel = this->cfg_single_ != nullptr ? this->cfg_single_ : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); @@ -664,7 +664,7 @@ bool USBUartComponent::run_config_machine_() { return true; } -void USBUartChannel::load_settings(bool /*dump_config*/) { +void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 5bb4c97796..00b34fb942 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -16,7 +16,7 @@ namespace esphome::usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; -class USBUartChannel; +class USBUartChannelBase; class USBUartTypePL2303; static const char *const TAG = "usb_uart"; @@ -110,7 +110,7 @@ class RingBuffer { struct UsbDataChunk { uint8_t data[usb_host::USB_MAX_PACKET_SIZE]; uint16_t length; - USBUartChannel *channel; + USBUartChannelBase *channel; // Required for EventPool - no cleanup needed for POD types void release() {} @@ -126,7 +126,11 @@ struct UsbOutputChunk { void release() {} }; -class USBUartChannel final : public uart::UARTComponent, public Parented { +// Common, non-final base for all USB UART channel implementations. +// Concrete channel types (USBUartChannel for CDC-style devices, vendor-specific +// multiplexed channels like CH934X) derive from this and are themselves final, +// per the "configurable classes are final" convention. +class USBUartChannelBase : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; @@ -139,7 +143,6 @@ class USBUartChannel final : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + // Not directly instantiable; construct a concrete channel type instead. + USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; @@ -185,33 +190,40 @@ class USBUartChannel final : public uart::UARTComponent, public Parented get_channels() { return this->channels_; } + std::vector get_channels() { return this->channels_; } - void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } + void add_channel(USBUartChannelBase *channel) { this->channels_.push_back(channel); } - virtual void start_input(USBUartChannel *channel); - void start_output(USBUartChannel *channel); + virtual void start_input(USBUartChannelBase *channel); + void start_output(USBUartChannelBase *channel); // Begin configuring all channels (full initialisation). Called from on_connected(). void enable_channels(); // Re-apply line settings to a single, already-open channel (used by - // USBUartChannel::load_settings()). - void apply_channel_settings(USBUartChannel *channel); + // USBUartChannelBase::load_settings()). + void apply_channel_settings(USBUartChannelBase *channel); // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. // Default is a no-op; override in device-specific subclasses that need resync on overflow. - virtual void on_rx_overflow(USBUartChannel *channel) {} + virtual void on_rx_overflow(USBUartChannelBase *channel) {} // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; - // Pool sized to queue capacity (SIZE-1) — see USBUartChannel::output_pool_ comment. + // Pool sized to queue capacity (SIZE-1) — see USBUartChannelBase::output_pool_ comment. EventPool chunk_pool_; protected: @@ -231,18 +243,19 @@ class USBUartComponent : public usb_host::USBClient { // next control transfer via config_transfer_() and return true, or return false when the // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. - virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + virtual bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) = 0; // Optional one-time device-level setup run before the per-channel phase on init only // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } - std::vector channels_{}; + std::vector channels_{}; // Config state machine - USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel - USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy - std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads - uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + USBUartChannelBase *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannelBase *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; bool cfg_active_{false}; @@ -260,7 +273,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -269,7 +282,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -277,7 +290,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; @@ -291,12 +304,12 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel) override; - void on_rx_overflow(USBUartChannel *channel) override; + void start_input(USBUartChannelBase *channel) override; + void on_rx_overflow(USBUartChannelBase *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -312,14 +325,14 @@ enum Pl2303ChipType : uint8_t { }; class USBUartTypePL2303 : public USBUartTypeCdcAcm { - friend class USBUartChannel; + friend class USBUartChannelBase; public: USBUartTypePL2303(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; From dcabaedff1b5adfb7d2070b7d1557e67d0eca8c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 18:59:24 -0500 Subject: [PATCH 208/470] [bk72xx_ble] Block BK7238 until the LibreTiny bonding partition fix lands (#18649) --- esphome/components/bk72xx_ble/__init__.py | 30 +++++++++---------- .../bk72xx_ble/config/test_bk7238.yaml | 7 +++++ .../bk72xx_ble/test_family_gate.py | 1 + 3 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7238.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 81073c9b02..74b9cb5954 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -4,9 +4,12 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. -Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in -to_code; unknown families are capability-checked at compile time via +Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2), +and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE +compiled in, the Beken SDK erases the bootloader flash sector at boot because +LibreTiny's partition table has no BLE bonding entry (esphome#18646, +libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in +to_code. Unknown families are capability-checked at compile time via `__has_include("app_ble.h")`, a header only on the BLE 5.x include path (ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build fails with a clear #error. @@ -65,6 +68,14 @@ def _unsupported_family_message(family: str) -> str | None: ) if family == FAMILY_BK7231Q: return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + if family == FAMILY_BK7238: + return ( + "bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK " + "erases the bootloader flash sector at boot and the device can no longer " + "start (see https://github.com/esphome/esphome/issues/18646); support " + "returns once the LibreTiny partition table fix " + "(libretiny-eu/libretiny#408) is released" + ) return None @@ -113,18 +124,7 @@ async def to_code(config: ConfigType) -> None: # BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is # derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++ # which path is available so it doesn't reference a missing symbol. - family = libretiny.get_libretiny_family() - if family == FAMILY_BK7231N: + if libretiny.get_libretiny_family() == FAMILY_BK7231N: cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") - elif family == FAMILY_BK7238: - # ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at - # WiFi STA startup when BLE init runs. This component re-enables BLE, so - # warn loudly: BK7238 is accepted but not hardware-verified and may be - # WiFi-unstable with BLE on. - _LOGGER.warning( - "bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup " - "hang on this family and is not yet hardware-verified. Expect possible " - "instability." - ) cg.add_define("USE_BK72XX_BLE") diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml new file mode 100644 index 0000000000..0880cf69f5 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7238 + +bk72xx: + board: generic-bk7238 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py index da67749bb3..86f3ef0039 100644 --- a/tests/component_tests/bk72xx_ble/test_family_gate.py +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -16,6 +16,7 @@ from esphome.core import EsphomeError ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), ("test_bk7252.yaml", "BK7251.*BLE 4.2"), ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ("test_bk7238.yaml", "BK7238.*bootloader"), ], ) def test_unsupported_family_rejected( From c062d0c7171a1e576fdb0bd8864e374be51a707c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:13 -0500 Subject: [PATCH 209/470] [ota] Log prepare, upload, and total OTA timing in espota2 (#18582) --- esphome/espota2.py | 18 ++++++++++++++++++ tests/unit_tests/test_espota2.py | 28 ++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 61e897f601..ca833f1816 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -460,8 +460,14 @@ def perform_ota( (upload_size >> 8) & 0xFF, (upload_size >> 0) & 0xFF, ] + # The device erases flash between receiving the size and acking the + # prepare, so this window shows the erase cost (near zero when the + # device erases lazily during the upload) + prepare_start = time.perf_counter() send_check(sock, upload_size_encoded, "binary size") receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) + prepare_duration = time.perf_counter() - prepare_start + _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) upload_md5 = hashlib.md5(upload_contents).hexdigest() _LOGGER.debug("MD5 of upload is %s", upload_md5) @@ -528,11 +534,23 @@ def perform_ota( # reboots on its own; the exact commit point is not observable from # here, so treat everything past the data phase as non-retryable. A # re-upload could flash a device that already updated successfully. + commit_start = time.perf_counter() try: receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) except OTANetworkError as err: raise _committed_error(err) from err + commit_duration = time.perf_counter() - commit_start + + # Sum of the named windows so the breakdown is self consistent; connect, + # handshake, auth, and the one MD5 round trip are not included + _LOGGER.info( + "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", + prepare_duration + duration + commit_duration, + prepare_duration, + duration, + commit_duration, + ) try: send_check(sock, RESPONSE_OK, "end acknowledgement") diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index db4a4b1117..e0e9185e1c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -6,6 +6,8 @@ from collections.abc import Generator import gzip import hashlib import io +import itertools +import logging from pathlib import Path import socket import struct @@ -53,8 +55,9 @@ def mock_sleep() -> Generator[Mock]: @pytest.fixture def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" - # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): + # Monotonically increasing, never exhausted regardless of how many timing + # windows perform_ota measures or how many times a test calls it + with patch("time.perf_counter", side_effect=itertools.count()): yield @@ -372,7 +375,9 @@ def test_perform_ota_successful_md5_auth( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: +def test_perform_ota_no_auth( + mock_socket: Mock, mock_file: io.BytesIO, caplog: pytest.LogCaptureFixture +) -> None: """Test OTA without authentication.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response @@ -387,7 +392,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + # Distinct window lengths pin each duration to its label; exactly the 6 + # expected perf_counter calls, so an unaccounted timing window raises + timings = [0.0, 2.0, 10.0, 15.0, 20.0, 27.0] + with ( + patch("time.perf_counter", side_effect=timings), + caplog.at_level(logging.INFO), + ): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # Should not send any auth-related data auth_calls = [ @@ -397,6 +409,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: ] assert len(auth_calls) == 0 + # The timing summary is the observable output of the upload; exact strings + # pin each duration to its label + assert "Preparing for upload took 2.00 seconds" in caplog.text + assert ( + "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" + in caplog.text + ) + @pytest.mark.usefixtures("mock_time") def test_perform_ota_with_compression(mock_socket: Mock) -> None: From 1f31e51446af7bf7d6a34c8dfc40861a9a7ce783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:37 -0500 Subject: [PATCH 210/470] [esphome] Inline the trivial OTA port accessors (#18625) --- esphome/components/esphome/ota/ota_esphome.cpp | 2 -- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cab725f704..9cbb25b373 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -588,8 +588,6 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { } float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } -void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0053ca6969..979e3f2d7d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -39,14 +39,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD /// Manually set the port OTA should listen on - void set_port(uint16_t port); + void set_port(uint16_t port) { this->port_ = port; } void setup() override; void dump_config() override; float get_setup_priority() const override; void loop() override; - uint16_t get_port() const; + uint16_t get_port() const { return this->port_; } protected: void handle_handshake_(); From 6aab523dd9e6c716d1f2f246598bbc786954ef0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:58 -0500 Subject: [PATCH 211/470] [esp32_ble] Log connection parameter update results (#18607) --- esphome/components/esp32_ble/ble.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index e2d79173ff..6e6fb0e30d 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -643,8 +643,28 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa App.wake_loop_threadsafe(); return; + // Log the result of connection parameter updates: a peer can reject or + // never answer an update, and without this the link silently stays on the + // old parameters (visible only as unexplained supervision timeouts). + case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: { + if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status); + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + else { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s, + param->update_conn_params.conn_int, param->update_conn_params.latency, + param->update_conn_params.timeout); + } +#endif + return; + } + // Ignore these GAP events as they are not relevant for our use case - case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm From 14499223fd1e1560faf034747f39bd2b2c28f8a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:58:13 -0500 Subject: [PATCH 212/470] [esp8266] Don't report stale crash state after hardware WDT resets (#18597) --- esphome/components/esp8266/crash_handler.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 91b0cf9082..dc79043f21 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -118,8 +118,6 @@ static const LogString *get_exception_cause(uint32_t cause) { } static const LogString *get_reset_reason(uint32_t reason) { - if (reason == REASON_WDT_RST) - return LOG_STR("Hardware WDT"); if (reason == REASON_EXCEPTION_RST) return LOG_STR("Exception"); if (reason == REASON_SOFT_WDT_RST) @@ -162,13 +160,20 @@ void crash_handler_log() { if (!is_crash_reason(resetInfo.reason)) return; + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + if (resetInfo.reason == REASON_WDT_RST) { + // A hardware WDT reset happens entirely in hardware: the postmortem hook + // never runs, so rst_info epc1/exccause and the RTC backtrace are + // leftovers from an earlier crash. Don't misattribute them (#18596). + ESP_LOGE(TAG, " Reason: Hardware WDT (no crash state is recorded for hardware WDT resets)"); + return; + } + // Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost). // Both resetInfo and RTC data survive until the next reset, so this can be // called multiple times (logger init + API subscribe) with the same result. uint32_t backtrace[MAX_BACKTRACE]; uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); - - ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match // the Arduino core's postmortem handler behavior. From 74bdf275d20ab138cd9950e4ee281a11c21b39cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:58:28 -0500 Subject: [PATCH 213/470] [core] Dump the main.cpp config comment with sorted keys (#18653) --- esphome/__main__.py | 6 ++++-- tests/unit_tests/test_main.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c1e05d2ea7..769b66ecc8 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -762,9 +762,11 @@ def _wrap_to_code(name, comp, yaml_util): async def wrapped(conf): cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: - conf_str = yaml_util.dump(conf) + # sort_keys: voluptuous fills defaults in set order, so an + # unsorted dump would churn main.cpp and relink every run + conf_str = yaml_util.dump(conf, sort_keys=True) conf_str = conf_str.replace("//", "") - # remove tailing \ to avoid multi-line comment warning + # remove trailing \ to avoid multi-line comment warning conf_str = conf_str.replace("\\\n", "\n") cg.add(cg.LineComment(indent(conf_str))) await coro(conf) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a40341e194..1cb710ca58 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -11,6 +11,7 @@ from pathlib import Path import re import sys import time +from types import SimpleNamespace from typing import Any, Self from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -18,7 +19,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange -from esphome import __main__ as main +from esphome import __main__ as main, yaml_util from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -29,6 +30,7 @@ from esphome.__main__ import ( _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, + _wrap_to_code, check_permissions, choose_upload_log_host, command_analyze_memory, @@ -116,6 +118,7 @@ from esphome.espota2 import ( OTA_TYPE_UPDATE_PARTITION_TABLE, ) from esphome.platformio import toolchain +from esphome.types import ConfigType from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -7130,3 +7133,28 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails( # Same tree, so the path comparison still finds them equal and stays silent assert not caplog.text + + +@pytest.mark.asyncio +async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: + """The config comment dumps with sorted keys: voluptuous fills schema + defaults in set-iteration order, so an unsorted dump would churn + main.cpp and relink the firmware on every run.""" + comments: list[str] = [] + + async def to_code(conf: ConfigType) -> None: + """Accept any config; only the wrapper's comment output matters.""" + + comp = SimpleNamespace(to_code=to_code, config_schema=object()) + wrapped = _wrap_to_code("demo", comp, yaml_util) + with patch("esphome.codegen.add", side_effect=lambda st: comments.append(str(st))): + # Nested on purpose: the real churn lives in nested action configs, + # so sorting must apply at every mapping level + await wrapped({"beta": 1, "alpha": {"z": 1, "a": 2}}) + first = "\n".join(comments) + comments.clear() + await wrapped({"alpha": {"a": 2, "z": 1}, "beta": 1}) + second = "\n".join(comments) + assert first == second + assert second.index("alpha") < second.index("beta") + assert second.index("a: 2") < second.index("z: 1") From 259e7182a350e16b1f70fe88a5bb0dff7fbfc546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:59:55 -0500 Subject: [PATCH 214/470] [esp32] Exclude esp_gdbstub from the build by default (#18604) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6ed6d9399..501c2e525f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -233,6 +233,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component + "esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_http_client", # HTTP client - only needed by http_request component "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation From a282cb095ec2dc4bb08e4109714b4d71768c4629 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:39 -0500 Subject: [PATCH 215/470] [ethernet] Inline the trivial EthernetComponent setters (#18618) --- .../ethernet/ethernet_component.cpp | 8 ---- .../components/ethernet/ethernet_component.h | 48 +++++++++---------- .../ethernet/ethernet_component_esp32.cpp | 20 +------- .../ethernet/ethernet_component_rp2.cpp | 7 --- 4 files changed, 25 insertions(+), 58 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 42cb0b3cfc..14a4fd660b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -10,14 +10,6 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non- EthernetComponent::EthernetComponent() { global_eth_component = this; } -float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } - -void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } - -#ifdef USE_ETHERNET_MANUAL_IP -void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } -#endif - #ifdef USE_ETHERNET_IP_STATE_LISTENERS void EthernetComponent::notify_ip_state_listeners_() { auto ips = this->get_ip_addresses(); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 646e0af8e6..2da070b5e0 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -125,7 +125,7 @@ class EthernetComponent final : public Component { void setup() override; void loop() override; void dump_config() override; - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::ETHERNET; } void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } @@ -146,9 +146,9 @@ class EthernetComponent final : public Component { esp_netif_t *get_esp_netif() { return this->eth_netif_; } #endif - void set_type(EthernetType type); + void set_type(EthernetType type) { this->type_ = type; } #ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); + void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } #endif void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } @@ -171,35 +171,35 @@ class EthernetComponent final : public Component { esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } #ifdef USE_ETHERNET_SPI - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(uint8_t interrupt_pin); - void set_reset_pin(uint8_t reset_pin); - void set_clock_speed(int clock_speed); - void set_interface(spi_host_device_t interface); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } + void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } + void set_interface(spi_host_device_t interface) { this->interface_ = interface; } #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - void set_polling_interval(uint32_t polling_interval); + void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif #else - void set_phy_addr(uint8_t phy_addr); - void set_power_pin(int power_pin); - void set_mdc_pin(uint8_t mdc_pin); - void set_mdio_pin(uint8_t mdio_pin); - void set_clk_pin(uint8_t clk_pin); - void set_clk_mode(emac_rmii_clock_mode_t clk_mode); + void set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } + void set_power_pin(int power_pin) { this->power_pin_ = power_pin; } + void set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } + void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } void add_phy_register(PHYRegister register_value); #endif // USE_ETHERNET_SPI #endif // USE_ESP32 #ifdef USE_RP2 - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(int8_t interrupt_pin); - void set_reset_pin(int8_t reset_pin); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } #endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 0220d6a19b..4af2d5f93c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -908,25 +908,7 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifdef USE_ETHERNET_SPI -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } -void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } -void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT -void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } -#endif -#else -void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } -void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } -void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } -void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } +#ifndef USE_ETHERNET_SPI void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 119e447689..7f4db4fab7 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -355,13 +355,6 @@ void EthernetComponent::dump_connect_params_() { this->get_eth_mac_address_pretty_into_buffer(mac_buf)); } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -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 From 0dc69aab1e3f6927cd6ee33804c69e2404a0728b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:49 -0500 Subject: [PATCH 216/470] [logger] Inline the trivial Logger accessors (#18619) --- esphome/components/logger/logger.cpp | 7 ------- esphome/components/logger/logger.h | 6 +++--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 6527b6aa8c..bfc005070e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -201,17 +201,10 @@ void Logger::process_messages_() { #endif // USE_ESPHOME_TASK_LOG_BUFFER } -void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) -UARTSelection Logger::get_uart() const { return this->uart_; } -#endif - -float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } - // Log level strings - packed into flash on ESP8266, indexed by log level (0-7) PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"); diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 69d8e6d32a..9c26814f7e 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -148,7 +148,7 @@ class Logger final : public Component { void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. - void set_baud_rate(uint32_t baud_rate); + void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } uint32_t get_baud_rate() const { return baud_rate_; } #if defined(USE_ARDUINO) && !defined(USE_ESP32) Stream *get_hw_serial() const { return hw_serial_; } @@ -163,7 +163,7 @@ class Logger final : public Component { #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. - UARTSelection get_uart() const; + UARTSelection get_uart() const { return this->uart_; } #endif /// Set the default log level for this logger. @@ -197,7 +197,7 @@ class Logger final : public Component { void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); } #endif - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT #ifdef USE_STORE_LOG_STR_IN_FLASH From 4db16660242dc4db15bef9fd3ab2bc3b96ce8ed7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:59 -0500 Subject: [PATCH 217/470] [light] Inline the trivial LightState accessors (#18620) --- esphome/components/light/esp_range_view.cpp | 2 -- esphome/components/light/esp_range_view.h | 3 +++ esphome/components/light/light_state.cpp | 18 ------------- esphome/components/light/light_state.h | 28 ++++++++++++--------- 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 58d552031a..5d372983d9 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -13,8 +13,6 @@ ESPColorView ESPRangeView::operator[](int32_t index) const { index = interpret_index(index, this->size()) + this->begin_; return (*this->parent_)[index]; } -ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } -ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } void ESPRangeView::set(const Color &color) { for (int32_t i = this->begin_; i < this->end_; i++) { diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index f5e4ebb83f..ec129bdf70 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -75,4 +75,7 @@ class ESPRangeIterator { int32_t i_; }; +inline ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } +inline ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } + } // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9d0181a05c..82c00e2382 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -157,8 +157,6 @@ void LightState::loop() { } } -float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } - void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { @@ -194,25 +192,11 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::set_default_transition_length(uint32_t default_transition_length) { - this->default_transition_length_ = default_transition_length; -} -uint32_t LightState::get_default_transition_length() const { return this->default_transition_length_; } -void LightState::set_flash_transition_length(uint32_t flash_transition_length) { - this->flash_transition_length_ = flash_transition_length; -} -uint32_t LightState::get_flash_transition_length() const { return this->flash_transition_length_; } -void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } -void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } -void LightState::set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } -bool LightState::supports_effects() { return !this->effects_.empty(); } -const FixedVector &LightState::get_effects() const { return this->effects_; } void LightState::add_effects(const std::initializer_list &effects) { // Called once from Python codegen during setup with all effects from YAML config this->effects_ = effects; } -void LightState::current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -333,8 +317,6 @@ float LightState::gamma_uncorrect_lut(float value) const { } #endif // USE_LIGHT_GAMMA_LUT -bool LightState::is_transformer_active() { return this->is_transformer_active_; } - void LightState::start_effect_(uint32_t effect_index) { this->stop_effect_(); if (effect_index == 0) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 5efc05358b..3a3f8fc368 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -109,7 +109,7 @@ class LightState : public EntityBase, public Component { void dump_config() override; void loop() override; /// Shortly after HARDWARE. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::HARDWARE - 1.0f; } /** The current values of the light as outputted to the light. * @@ -157,15 +157,19 @@ class LightState : public EntityBase, public Component { void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. - void set_default_transition_length(uint32_t default_transition_length); - uint32_t get_default_transition_length() const; + void set_default_transition_length(uint32_t default_transition_length) { + this->default_transition_length_ = default_transition_length; + } + uint32_t get_default_transition_length() const { return this->default_transition_length_; } /// Set the flash transition length - void set_flash_transition_length(uint32_t flash_transition_length); - uint32_t get_flash_transition_length() const; + void set_flash_transition_length(uint32_t flash_transition_length) { + this->flash_transition_length_ = flash_transition_length; + } + uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct); + void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } float get_gamma_correct() const { return this->gamma_correct_; } #ifdef USE_LIGHT_GAMMA_LUT @@ -186,17 +190,17 @@ class LightState : public EntityBase, public Component { #endif // USE_LIGHT_GAMMA_LUT /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode); + void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } /// Set a callback to populate the initial state defaults during setup. /// The callback is called once, then cleared. Values live in flash as code. - void set_initial_state(void (*callback)(LightStateRTCState &)); + void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } /// Return whether the light has any effects that meet the trait requirements. - bool supports_effects(); + bool supports_effects() const { return !this->effects_.empty(); } /// Get all effects for this light state. - const FixedVector &get_effects() const; + const FixedVector &get_effects() const { return this->effects_; } /// Add effects for this light state. void add_effects(const std::initializer_list &effects); @@ -254,7 +258,7 @@ class LightState : public EntityBase, public Component { } /// The result of all the current_values_as_* methods have gamma correction applied. - void current_values_as_binary(bool *binary); + void current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void current_values_as_brightness(float *brightness); @@ -281,7 +285,7 @@ class LightState : public EntityBase, public Component { * return; * } */ - bool is_transformer_active(); + bool is_transformer_active() const { return this->is_transformer_active_; } protected: friend LightOutput; From 763a1d9371690543487037fa97a53d2bb2ea03a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:16 -0500 Subject: [PATCH 218/470] [select] Inline the trivial Select accessors (#18621) --- esphome/components/select/select.cpp | 17 ----------------- esphome/components/select/select.h | 14 ++++++++------ esphome/components/select/select_traits.cpp | 2 -- esphome/components/select/select_traits.h | 2 +- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 17c6c811dd..05a0ee1ed9 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -8,8 +8,6 @@ namespace esphome::select { static const char *const TAG = "select"; -void Select::publish_state(const std::string &state) { this->publish_state(state.c_str()); } - void Select::publish_state(const char *state) { auto index = this->index_of(state); if (index.has_value()) { @@ -34,21 +32,6 @@ void Select::publish_state(size_t index) { #endif } -StringRef Select::current_option() const { - return this->has_state() ? StringRef(this->option_at(this->active_index_)) : StringRef(); -} - -bool Select::has_option(const std::string &option) const { return this->index_of(option.c_str()).has_value(); } - -bool Select::has_option(const char *option) const { return this->index_of(option).has_value(); } - -bool Select::has_index(size_t index) const { return index < this->size(); } - -size_t Select::size() const { - const auto &options = traits.get_options(); - return options.size(); -} - optional Select::index_of(const char *option, size_t len) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 34d9248523..2294f34e62 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -33,27 +33,29 @@ class Select : public EntityBase { Select() = default; ~Select() = default; - void publish_state(const std::string &state); + void publish_state(const std::string &state) { this->publish_state(state.c_str()); } void publish_state(const char *state); void publish_state(size_t index); /// Return the currently selected option, or empty StringRef if no state. /// The returned StringRef points to string literals from codegen (static storage). /// Traits are set once at startup and valid for the lifetime of the program. - StringRef current_option() const; + StringRef current_option() const { + return this->has_state() ? StringRef(this->option_at(this->active_index_)) : StringRef(); + } /// Instantiate a SelectCall object to modify this select component's state. SelectCall make_call() { return SelectCall(this); } /// Return whether this select component contains the provided option. - bool has_option(const std::string &option) const; - bool has_option(const char *option) const; + bool has_option(const std::string &option) const { return this->index_of(option).has_value(); } + bool has_option(const char *option) const { return this->index_of(option).has_value(); } /// Return whether this select component contains the provided index offset. - bool has_index(size_t index) const; + bool has_index(size_t index) const { return index < this->size(); } /// Return the number of options in this select component. - size_t size() const; + size_t size() const { return this->traits.get_options().size(); } /// Find the (optional) index offset of the provided option value. optional index_of(const char *option, size_t len) const; diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index ff52c0d85b..67a5118646 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -11,6 +11,4 @@ void SelectTraits::set_options(const FixedVector &options) { } } -const FixedVector &SelectTraits::get_options() const { return this->options_; } - } // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index 78a83e5944..e1b261bc96 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -9,7 +9,7 @@ class SelectTraits { public: void set_options(const std::initializer_list &options); void set_options(const FixedVector &options); - const FixedVector &get_options() const; + const FixedVector &get_options() const { return this->options_; } protected: FixedVector options_; From c60062c418b3a2e2fb841557d611bd0f341ae551 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:28 -0500 Subject: [PATCH 219/470] [sensor] Inline the trivial ExponentialMovingAverageFilter setters (#18622) --- esphome/components/sensor/filter.cpp | 2 -- esphome/components/sensor/filter.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 0105580d26..dbd6f4d34b 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -164,8 +164,6 @@ optional ExponentialMovingAverageFilter::new_value(float value) { } return {}; } -void ExponentialMovingAverageFilter::set_send_every(uint16_t send_every) { this->send_every_ = send_every; } -void ExponentialMovingAverageFilter::set_alpha(float alpha) { this->alpha_ = alpha; } // ThrottleAverageFilter ThrottleAverageFilter::ThrottleAverageFilter(uint32_t time_period) : time_period_(time_period) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index b79bfa17d6..bc086e3805 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -239,8 +239,8 @@ class ExponentialMovingAverageFilter : public Filter { optional new_value(float value) override; - void set_send_every(uint16_t send_every); - void set_alpha(float alpha); + void set_send_every(uint16_t send_every) { this->send_every_ = send_every; } + void set_alpha(float alpha) { this->alpha_ = alpha; } protected: float accumulator_{NAN}; From efc0a94112f93d25b9806a332310a877faebe6b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:42 -0500 Subject: [PATCH 220/470] [text_sensor] Inline the trivial TextSensor forwarding overloads (#18623) --- esphome/components/text_sensor/text_sensor.cpp | 8 -------- esphome/components/text_sensor/text_sensor.h | 8 +++++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index d2483619a6..17c606d253 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -18,10 +18,6 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text LOG_ENTITY_ICON(tag, prefix, *obj); } -void TextSensor::publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } - -void TextSensor::publish_state(const char *state) { this->publish_state(state, strlen(state)); } - void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER if (this->filter_list_ == nullptr) { @@ -91,10 +87,6 @@ const std::string &TextSensor::get_raw_state() const { #endif return this->state; // No filters, raw == filtered } -void TextSensor::internal_send_state_to_frontend(const std::string &state) { - this->internal_send_state_to_frontend(state.data(), state.size()); -} - void TextSensor::internal_send_state_to_frontend(const char *state, size_t len) { // Only assign if changed to avoid heap allocation if (len != this->state.size() || memcmp(state, this->state.data(), len) != 0) { diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index aa48781f41..0e7364bf98 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -37,8 +37,8 @@ class TextSensor : public EntityBase { /// Returns the raw (pre-filter) state. const std::string &get_raw_state() const; - void publish_state(const std::string &state); - void publish_state(const char *state); + void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } + void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); #ifdef USE_TEXT_SENSOR_FILTER @@ -70,7 +70,9 @@ class TextSensor : public EntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void internal_send_state_to_frontend(const std::string &state); + void internal_send_state_to_frontend(const std::string &state) { + this->internal_send_state_to_frontend(state.data(), state.size()); + } void internal_send_state_to_frontend(const char *state, size_t len); protected: From 5c2286cc4a1ea1cd3392df32ec1dc27f4ca4a27b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:55 -0500 Subject: [PATCH 221/470] [climate] Inline the trivial visual override setters (#18624) --- esphome/components/climate/climate.cpp | 23 ----------------------- esphome/components/climate/climate.h | 21 ++++++++++++++++----- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index b41ca4a540..0f01443bd0 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -511,29 +511,6 @@ ClimateTraits Climate::get_traits() { return traits; } -#ifdef USE_CLIMATE_VISUAL_OVERRIDES -void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { - this->visual_min_temperature_override_ = visual_min_temperature_override; -} - -void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) { - this->visual_max_temperature_override_ = visual_max_temperature_override; -} - -void Climate::set_visual_temperature_step_override(float target, float current) { - this->visual_target_temperature_step_override_ = target; - this->visual_current_temperature_step_override_ = current; -} - -void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) { - this->visual_min_humidity_override_ = visual_min_humidity_override; -} - -void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { - this->visual_max_humidity_override_ = visual_max_humidity_override; -} -#endif - ClimateCall Climate::make_call() { return ClimateCall(this); } ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 04f653a2b0..a906897235 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -228,11 +228,22 @@ class Climate : public EntityBase { ClimateTraits get_traits(); #ifdef USE_CLIMATE_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float visual_min_temperature_override); - void set_visual_max_temperature_override(float visual_max_temperature_override); - void set_visual_temperature_step_override(float target, float current); - void set_visual_min_humidity_override(float visual_min_humidity_override); - void set_visual_max_humidity_override(float visual_max_humidity_override); + void set_visual_min_temperature_override(float visual_min_temperature_override) { + this->visual_min_temperature_override_ = visual_min_temperature_override; + } + void set_visual_max_temperature_override(float visual_max_temperature_override) { + this->visual_max_temperature_override_ = visual_max_temperature_override; + } + void set_visual_temperature_step_override(float target, float current) { + this->visual_target_temperature_step_override_ = target; + this->visual_current_temperature_step_override_ = current; + } + void set_visual_min_humidity_override(float visual_min_humidity_override) { + this->visual_min_humidity_override_ = visual_min_humidity_override; + } + void set_visual_max_humidity_override(float visual_max_humidity_override) { + this->visual_max_humidity_override_ = visual_max_humidity_override; + } #endif /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). From ce019f508d3a78a31e17bf71f98a70ef0d63419e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:04 -0500 Subject: [PATCH 222/470] [safe_mode] Inline the trivial set_safe_mode setters (#18626) --- esphome/components/safe_mode/button/safe_mode_button.cpp | 4 ---- esphome/components/safe_mode/button/safe_mode_button.h | 2 +- esphome/components/safe_mode/switch/safe_mode_switch.cpp | 4 ---- esphome/components/safe_mode/switch/safe_mode_switch.h | 2 +- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/safe_mode/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp index 04203854fb..982ecf8402 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ b/esphome/components/safe_mode/button/safe_mode_button.cpp @@ -7,10 +7,6 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode.button"; -void SafeModeButton::set_safe_mode(SafeModeComponent *safe_mode_component) { - this->safe_mode_component_ = safe_mode_component; -} - void SafeModeButton::press_action() { ESP_LOGI(TAG, "Restarting in safe mode"); this->safe_mode_component_->set_safe_mode_pending(true); diff --git a/esphome/components/safe_mode/button/safe_mode_button.h b/esphome/components/safe_mode/button/safe_mode_button.h index 6012bb2aeb..035bd77802 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.h +++ b/esphome/components/safe_mode/button/safe_mode_button.h @@ -9,7 +9,7 @@ namespace esphome::safe_mode { class SafeModeButton final : public button::Button, public Component { public: void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component); + void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } protected: SafeModeComponent *safe_mode_component_; diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.cpp b/esphome/components/safe_mode/switch/safe_mode_switch.cpp index f513465db0..b4b9735757 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.cpp +++ b/esphome/components/safe_mode/switch/safe_mode_switch.cpp @@ -7,10 +7,6 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode.switch"; -void SafeModeSwitch::set_safe_mode(SafeModeComponent *safe_mode_component) { - this->safe_mode_component_ = safe_mode_component; -} - void SafeModeSwitch::write_state(bool state) { // Acknowledge this->publish_state(false); diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index cbd79cd520..cb48023f63 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -9,7 +9,7 @@ namespace esphome::safe_mode { class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component); + void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } protected: SafeModeComponent *safe_mode_component_; From dba3b287dd817b9ad68941b7fd1a8294bdc4a616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:14 -0500 Subject: [PATCH 223/470] [api] Inline the trivial APIServer accessors (#18627) --- esphome/components/api/api_server.cpp | 10 ---------- esphome/components/api/api_server.h | 10 +++++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ef5b43d7b1..2d5f9e4155 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -423,12 +423,6 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif -float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; } - -void APIServer::set_port(uint16_t port) { this->port_ = port; } - -void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } - #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { bool has_subscriber = false; @@ -553,10 +547,6 @@ const std::vector &APIServer::get_sta } #endif -uint16_t APIServer::get_port() const { return this->port_; } - -void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } - #ifdef USE_API_NOISE bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 248b83a0ff..a58e42534b 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -51,8 +51,8 @@ class APIServer final : public Component, public: APIServer(); void setup() override; - uint16_t get_port() const; - float get_setup_priority() const override; + uint16_t get_port() const { return this->port_; } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void loop() override; void dump_config() override; void on_shutdown() override; @@ -63,9 +63,9 @@ class APIServer final : public Component, #ifdef USE_CAMERA void on_camera_image(const std::shared_ptr &image) override; #endif - void set_port(uint16_t port); - void set_reboot_timeout(uint32_t reboot_timeout); - void set_batch_delay(uint16_t batch_delay); + void set_port(uint16_t port) { this->port_ = port; } + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } + void set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } From 0e915e9b8bf709acd8b63258cfb6d7bd27b2a85b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:58 -0500 Subject: [PATCH 224/470] [core] Inline the ESPTime::strftime std::string overload (#18628) --- esphome/core/time.cpp | 2 -- esphome/core/time.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index b6fc9b90ad..d1ba981e95 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -114,8 +114,6 @@ std::string ESPTime::strftime(const char *format) { return std::string(buf, len); } -std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } - // Helper to parse exactly N digits, returns false if not enough digits static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) { value = 0; diff --git a/esphome/core/time.h b/esphome/core/time.h index 0b67b7b3fc..f58cf20b4e 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -71,7 +71,7 @@ struct ESPTime { * @warning This method can return "ERROR" when the underlying strftime() call fails or when the * output exceeds STRFTIME_BUFFER_SIZE bytes. */ - std::string strftime(const std::string &format); + std::string strftime(const std::string &format) { return this->strftime(format.c_str()); } /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); From 3ef5a8e6a4cca3f061712c39a8757c8e1a001eb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:07 -0500 Subject: [PATCH 225/470] [water_heater] Inline the trivial visual override setters (#18629) --- esphome/components/water_heater/water_heater.cpp | 12 ------------ esphome/components/water_heater/water_heater.h | 12 +++++++++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 9ee8faadee..9862253ad9 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -233,18 +233,6 @@ WaterHeaterTraits WaterHeater::get_traits() { return traits; } -#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES -void WaterHeater::set_visual_min_temperature_override(float min_temperature_override) { - this->visual_min_temperature_override_ = min_temperature_override; -} -void WaterHeater::set_visual_max_temperature_override(float max_temperature_override) { - this->visual_max_temperature_override_ = max_temperature_override; -} -void WaterHeater::set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { - this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; -} -#endif - // Water heater mode strings indexed by WaterHeaterMode enum (0-6): OFF, ECO, ELECTRIC, PERFORMANCE, HIGH_DEMAND, // HEAT_PUMP, GAS PROGMEM_STRING_TABLE(WaterHeaterModeStrings, "OFF", "ECO", "ELECTRIC", "PERFORMANCE", "HIGH_DEMAND", "HEAT_PUMP", "GAS", diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 995b815440..1255a68595 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -217,9 +217,15 @@ class WaterHeater : public EntityBase { virtual WaterHeaterCallInternal make_call() = 0; #ifdef USE_WATER_HEATER_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float min_temperature_override); - void set_visual_max_temperature_override(float max_temperature_override); - void set_visual_target_temperature_step_override(float visual_target_temperature_step_override); + void set_visual_min_temperature_override(float min_temperature_override) { + this->visual_min_temperature_override_ = min_temperature_override; + } + void set_visual_max_temperature_override(float max_temperature_override) { + this->visual_max_temperature_override_ = max_temperature_override; + } + void set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { + this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; + } #endif virtual void control(const WaterHeaterCall &call) = 0; From cb4e55e4449b08dac696d38dc4314216a8c9b332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:15 -0500 Subject: [PATCH 226/470] [cover] Inline the trivial Cover and CoverCall accessors (#18630) --- esphome/components/cover/cover.cpp | 7 ------- esphome/components/cover/cover.h | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index e98a555fe5..dc2db3bf32 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -135,10 +135,6 @@ CoverCall &CoverCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool CoverCall::get_stop() const { return this->stop_; } - -CoverCall Cover::make_call() { return {this}; } - void Cover::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); this->tilt = clamp(this->tilt, 0.0f, 1.0f); @@ -184,9 +180,6 @@ optional Cover::restore_state_() { return recovered; } -bool Cover::is_fully_open() const { return this->position == COVER_OPEN; } -bool Cover::is_fully_closed() const { return this->position == COVER_CLOSED; } - CoverCall CoverRestoreState::to_call(Cover *cover) { auto call = cover->make_call(); auto traits = cover->get_traits(); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 9a75e68487..8bf45cfb57 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -50,7 +50,7 @@ class CoverCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_tilt() const; const optional &get_toggle() const; @@ -123,7 +123,7 @@ class Cover : public EntityBase { float tilt{COVER_OPEN}; /// Construct a new cover call used to control the cover. - CoverCall make_call(); + CoverCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -139,9 +139,9 @@ class Cover : public EntityBase { virtual CoverTraits get_traits() = 0; /// Helper method to check if the cover is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == COVER_OPEN; } /// Helper method to check if the cover is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == COVER_CLOSED; } protected: friend CoverCall; From fedb3ac5c1999f03eb4f47f1b63c2d23b37ed47f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:38 -0500 Subject: [PATCH 227/470] [fan] Inline the trivial Fan call helpers (#18631) --- esphome/components/fan/fan.cpp | 5 ----- esphome/components/fan/fan.h | 8 ++++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 853bf94ffe..7dc0b5c6fe 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -153,11 +153,6 @@ void FanRestoreState::apply(Fan &fan) { fan.publish_state(); } -FanCall Fan::turn_on() { return this->make_call().set_state(true); } -FanCall Fan::turn_off() { return this->make_call().set_state(false); } -FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } -FanCall Fan::make_call() { return FanCall(*this); } - const char *Fan::find_preset_mode_(const char *preset_mode) { return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 3d731e6eb0..106e6e74cd 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -115,10 +115,10 @@ class Fan : public EntityBase { /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; - FanCall turn_on(); - FanCall turn_off(); - FanCall toggle(); - FanCall make_call(); + FanCall turn_on() { return this->make_call().set_state(true); } + FanCall turn_off() { return this->make_call().set_state(false); } + FanCall toggle() { return this->make_call().set_state(!this->state); } + FanCall make_call() { return FanCall(*this); } /// Register a callback that will be called each time the state changes. template void add_on_state_callback(F &&callback) { From 832a738588e2d308e981c71a6620e894a2ac356d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:51 -0500 Subject: [PATCH 228/470] [switch] Inline the trivial inverted accessors (#18632) --- esphome/components/switch/switch.cpp | 3 --- esphome/components/switch/switch.h | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index abc7338a62..101a0b9ffa 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -69,9 +69,6 @@ void Switch::publish_state(bool state) { } bool Switch::assumed_state() { return false; } -void Switch::set_inverted(bool inverted) { this->inverted_ = inverted; } -bool Switch::is_inverted() const { return this->inverted_; } - void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj) { if (obj != nullptr) { // Prepare restore mode string diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index b7761cba0a..0564c3efd2 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -87,7 +87,7 @@ class Switch : public EntityBase { * * @param inverted Whether to invert this switch. */ - void set_inverted(bool inverted); + void set_inverted(bool inverted) { this->inverted_ = inverted; } /** Set callback for state changes. * @@ -117,7 +117,7 @@ class Switch : public EntityBase { */ virtual bool assumed_state(); - bool is_inverted() const; + bool is_inverted() const { return this->inverted_; } void set_restore_mode(SwitchRestoreMode restore_mode) { this->restore_mode = restore_mode; } From ad1a4fca3653f4cc98da63abe7b66f434f7ee66c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:08 -0500 Subject: [PATCH 229/470] [version] Inline the trivial VersionTextSensor setters (#18633) --- esphome/components/version/version_text_sensor.cpp | 2 -- esphome/components/version/version_text_sensor.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 34c7aae6bc..15e6b0d088 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -48,8 +48,6 @@ void VersionTextSensor::setup() { version_str[sizeof(version_str) - 1] = '\0'; this->publish_state(version_str); } -void VersionTextSensor::set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } -void VersionTextSensor::set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void VersionTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Version Text Sensor", this); } } // namespace esphome::version diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index d2ca0ba6f6..96f72ad035 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,8 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: - void set_hide_hash(bool hide_hash); - void set_hide_timestamp(bool hide_timestamp); + void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } + void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; void dump_config() override; From ecb007da70a94f6605d01f8d775c27a639ae5e02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:23 -0500 Subject: [PATCH 230/470] [deep_sleep] Inline the trivial DeepSleepComponent setters (#18634) --- esphome/components/deep_sleep/deep_sleep_component.cpp | 8 -------- esphome/components/deep_sleep/deep_sleep_component.h | 10 +++++----- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 6 ------ 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index e7ce70b60c..9a3e537e05 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -43,10 +43,6 @@ void DeepSleepComponent::loop() { this->begin_sleep(); } -void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } - -void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } - void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { this->next_enter_deep_sleep_ = true; @@ -76,8 +72,4 @@ void DeepSleepComponent::begin_sleep(bool manual) { float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } -void DeepSleepComponent::prevent_deep_sleep() { this->prevent_ = true; } - -void DeepSleepComponent::allow_deep_sleep() { this->prevent_ = false; } - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index a620d52a02..208f88d707 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -132,7 +132,7 @@ template class PreventDeepSleepAction; class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. - void set_sleep_duration(uint32_t time_ms); + void set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } #if defined(USE_ESP32) /** Set the pin to wake up to on the ESP32 once it's in deep sleep mode. * Use the inverted property to set the wakeup level. @@ -157,7 +157,7 @@ class DeepSleepComponent final : public Component { #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) - void set_touch_wakeup(bool touch_wakeup); + void set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif // Set the duration in ms for how long the code should run before entering @@ -166,7 +166,7 @@ class DeepSleepComponent final : public Component { #endif // USE_ESP32 /// Set a duration in ms for how long the code should run before entering deep sleep mode. - void set_run_duration(uint32_t time_ms); + void set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } void setup() override; void dump_config() override; @@ -176,8 +176,8 @@ class DeepSleepComponent final : public Component { /// Helper to enter deep sleep mode void begin_sleep(bool manual = false); - void prevent_deep_sleep(); - void allow_deep_sleep(); + void prevent_deep_sleep() { this->prevent_ = true; } + void allow_deep_sleep() { this->prevent_ = false; } protected: // Returns nullopt if no run duration is set. Otherwise, returns the run diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index f64e1f37e1..3fa1a1f1ed 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -74,12 +74,6 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) { void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; } #endif -#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) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } -#endif - void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { wakeup_cause_to_run_duration_ = wakeup_cause_to_run_duration; } From 5a9f06e584ac8e771f9aaca53ae85574f5d7776b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:41 -0500 Subject: [PATCH 231/470] [thermostat] Inline the trivial ThermostatClimate setters and getters (#18635) --- .../thermostat/thermostat_climate.cpp | 95 -------------- .../thermostat/thermostat_climate.h | 116 +++++++++++------- 2 files changed, 74 insertions(+), 137 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 2390a96337..c10eb5b9f5 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -76,11 +76,6 @@ void ThermostatClimate::loop() { } } -float ThermostatClimate::cool_deadband() { return this->cooling_deadband_; } -float ThermostatClimate::cool_overrun() { return this->cooling_overrun_; } -float ThermostatClimate::heat_deadband() { return this->heating_deadband_; } -float ThermostatClimate::heat_overrun() { return this->heating_overrun_; } - void ThermostatClimate::refresh() { this->switch_to_mode_(this->mode, false); this->switch_to_action_(this->compute_action_(), false); @@ -121,8 +116,6 @@ bool ThermostatClimate::fan_mode_change_delayed() { climate::ClimateAction ThermostatClimate::delayed_climate_action() { return this->compute_action_(true); } -climate::ClimateFanMode ThermostatClimate::locked_fan_mode() { return this->prev_fan_mode_; } - bool ThermostatClimate::hysteresis_valid() { if ((this->supports_cool_ || (this->supports_fan_only_ && this->supports_fan_only_cooling_)) && (std::isnan(this->cooling_deadband_) || std::isnan(this->cooling_overrun_))) @@ -1286,10 +1279,6 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_preset_config(std::initializer_list presets) { - this->preset_config_ = presets; -} - void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { this->custom_preset_config_ = presets; // Populate Climate base class custom presets vector @@ -1317,19 +1306,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } -void ThermostatClimate::set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { - this->on_boot_restore_from_ = on_boot_restore_from; -} -void ThermostatClimate::set_set_point_minimum_differential(float differential) { - this->set_point_minimum_differential_ = differential; -} -void ThermostatClimate::set_cool_deadband(float deadband) { this->cooling_deadband_ = deadband; } -void ThermostatClimate::set_cool_overrun(float overrun) { this->cooling_overrun_ = overrun; } -void ThermostatClimate::set_heat_deadband(float deadband) { this->heating_deadband_ = deadband; } -void ThermostatClimate::set_heat_overrun(float overrun) { this->heating_overrun_ = overrun; } -void ThermostatClimate::set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } -void ThermostatClimate::set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); @@ -1389,80 +1365,9 @@ void ThermostatClimate::set_heating_minimum_run_time_in_sec(uint32_t time) { void ThermostatClimate::set_idle_minimum_time_in_sec(uint32_t time) { this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_IDLE_ON, time); } -void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void ThermostatClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { - this->humidity_sensor_ = humidity_sensor; -} void ThermostatClimate::set_humidity_hysteresis(float humidity_hysteresis) { this->humidity_hysteresis_ = std::clamp(humidity_hysteresis, 0.0f, 100.0f); } -void ThermostatClimate::set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } -void ThermostatClimate::set_supports_heat_cool(bool supports_heat_cool) { - this->supports_heat_cool_ = supports_heat_cool; -} -void ThermostatClimate::set_supports_auto(bool supports_auto) { this->supports_auto_ = supports_auto; } -void ThermostatClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void ThermostatClimate::set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } -void ThermostatClimate::set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } -void ThermostatClimate::set_supports_fan_only_action_uses_fan_mode_timer( - bool supports_fan_only_action_uses_fan_mode_timer) { - this->supports_fan_only_action_uses_fan_mode_timer_ = supports_fan_only_action_uses_fan_mode_timer; -} -void ThermostatClimate::set_supports_fan_only_cooling(bool supports_fan_only_cooling) { - this->supports_fan_only_cooling_ = supports_fan_only_cooling; -} -void ThermostatClimate::set_supports_fan_with_cooling(bool supports_fan_with_cooling) { - this->supports_fan_with_cooling_ = supports_fan_with_cooling; -} -void ThermostatClimate::set_supports_fan_with_heating(bool supports_fan_with_heating) { - this->supports_fan_with_heating_ = supports_fan_with_heating; -} -void ThermostatClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } -void ThermostatClimate::set_supports_fan_mode_on(bool supports_fan_mode_on) { - this->supports_fan_mode_on_ = supports_fan_mode_on; -} -void ThermostatClimate::set_supports_fan_mode_off(bool supports_fan_mode_off) { - this->supports_fan_mode_off_ = supports_fan_mode_off; -} -void ThermostatClimate::set_supports_fan_mode_auto(bool supports_fan_mode_auto) { - this->supports_fan_mode_auto_ = supports_fan_mode_auto; -} -void ThermostatClimate::set_supports_fan_mode_low(bool supports_fan_mode_low) { - this->supports_fan_mode_low_ = supports_fan_mode_low; -} -void ThermostatClimate::set_supports_fan_mode_medium(bool supports_fan_mode_medium) { - this->supports_fan_mode_medium_ = supports_fan_mode_medium; -} -void ThermostatClimate::set_supports_fan_mode_high(bool supports_fan_mode_high) { - this->supports_fan_mode_high_ = supports_fan_mode_high; -} -void ThermostatClimate::set_supports_fan_mode_middle(bool supports_fan_mode_middle) { - this->supports_fan_mode_middle_ = supports_fan_mode_middle; -} -void ThermostatClimate::set_supports_fan_mode_focus(bool supports_fan_mode_focus) { - this->supports_fan_mode_focus_ = supports_fan_mode_focus; -} -void ThermostatClimate::set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse) { - this->supports_fan_mode_diffuse_ = supports_fan_mode_diffuse; -} -void ThermostatClimate::set_supports_fan_mode_quiet(bool supports_fan_mode_quiet) { - this->supports_fan_mode_quiet_ = supports_fan_mode_quiet; -} -void ThermostatClimate::set_supports_swing_mode_both(bool supports_swing_mode_both) { - this->supports_swing_mode_both_ = supports_swing_mode_both; -} -void ThermostatClimate::set_supports_swing_mode_off(bool supports_swing_mode_off) { - this->supports_swing_mode_off_ = supports_swing_mode_off; -} -void ThermostatClimate::set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal) { - this->supports_swing_mode_horizontal_ = supports_swing_mode_horizontal; -} -void ThermostatClimate::set_supports_swing_mode_vertical(bool supports_swing_mode_vertical) { - this->supports_swing_mode_vertical_ = supports_swing_mode_vertical; -} -void ThermostatClimate::set_supports_two_points(bool supports_two_points) { - this->supports_two_points_ = supports_two_points; -} void ThermostatClimate::set_supports_dehumidification(bool supports_dehumidification) { this->supports_dehumidification_ = supports_dehumidification; if (supports_dehumidification) { diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index f30659a8a6..4dc2a74d8e 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -93,14 +93,16 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_default_preset(const char *custom_preset); void set_default_preset(climate::ClimatePreset preset); - void set_on_boot_restore_from(OnBootRestoreFrom on_boot_restore_from); - void set_set_point_minimum_differential(float differential); - void set_cool_deadband(float deadband); - void set_cool_overrun(float overrun); - void set_heat_deadband(float deadband); - void set_heat_overrun(float overrun); - void set_supplemental_cool_delta(float delta); - void set_supplemental_heat_delta(float delta); + void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { + this->on_boot_restore_from_ = on_boot_restore_from; + } + void set_set_point_minimum_differential(float differential) { this->set_point_minimum_differential_ = differential; } + void set_cool_deadband(float deadband) { this->cooling_deadband_ = deadband; } + void set_cool_overrun(float overrun) { this->cooling_overrun_ = overrun; } + void set_heat_deadband(float deadband) { this->heating_deadband_ = deadband; } + void set_heat_overrun(float overrun) { this->heating_overrun_ = overrun; } + void set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } + void set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } void set_cooling_maximum_run_time_in_sec(uint32_t time); void set_heating_maximum_run_time_in_sec(uint32_t time); void set_cooling_minimum_off_time_in_sec(uint32_t time); @@ -111,39 +113,69 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_heating_minimum_off_time_in_sec(uint32_t time); void set_heating_minimum_run_time_in_sec(uint32_t time); void set_idle_minimum_time_in_sec(uint32_t time); - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void set_humidity_hysteresis(float humidity_hysteresis); - void set_use_startup_delay(bool use_startup_delay); - void set_supports_auto(bool supports_auto); - void set_supports_heat_cool(bool supports_heat_cool); - void set_supports_cool(bool supports_cool); - void set_supports_dry(bool supports_dry); - void set_supports_fan_only(bool supports_fan_only); - void set_supports_fan_only_action_uses_fan_mode_timer(bool fan_only_action_uses_fan_mode_timer); - void set_supports_fan_only_cooling(bool supports_fan_only_cooling); - void set_supports_fan_with_cooling(bool supports_fan_with_cooling); - void set_supports_fan_with_heating(bool supports_fan_with_heating); - void set_supports_heat(bool supports_heat); - void set_supports_fan_mode_on(bool supports_fan_mode_on); - void set_supports_fan_mode_off(bool supports_fan_mode_off); - void set_supports_fan_mode_auto(bool supports_fan_mode_auto); - void set_supports_fan_mode_low(bool supports_fan_mode_low); - void set_supports_fan_mode_medium(bool supports_fan_mode_medium); - void set_supports_fan_mode_high(bool supports_fan_mode_high); - void set_supports_fan_mode_middle(bool supports_fan_mode_middle); - void set_supports_fan_mode_focus(bool supports_fan_mode_focus); - void set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse); - void set_supports_fan_mode_quiet(bool supports_fan_mode_quiet); - void set_supports_swing_mode_both(bool supports_swing_mode_both); - void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal); - void set_supports_swing_mode_off(bool supports_swing_mode_off); - void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical); + void set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } + void set_supports_auto(bool supports_auto) { this->supports_auto_ = supports_auto; } + void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } + void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } + void set_supports_fan_only_action_uses_fan_mode_timer(bool supports_fan_only_action_uses_fan_mode_timer) { + this->supports_fan_only_action_uses_fan_mode_timer_ = supports_fan_only_action_uses_fan_mode_timer; + } + void set_supports_fan_only_cooling(bool supports_fan_only_cooling) { + this->supports_fan_only_cooling_ = supports_fan_only_cooling; + } + void set_supports_fan_with_cooling(bool supports_fan_with_cooling) { + this->supports_fan_with_cooling_ = supports_fan_with_cooling; + } + void set_supports_fan_with_heating(bool supports_fan_with_heating) { + this->supports_fan_with_heating_ = supports_fan_with_heating; + } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_fan_mode_on(bool supports_fan_mode_on) { this->supports_fan_mode_on_ = supports_fan_mode_on; } + void set_supports_fan_mode_off(bool supports_fan_mode_off) { this->supports_fan_mode_off_ = supports_fan_mode_off; } + void set_supports_fan_mode_auto(bool supports_fan_mode_auto) { + this->supports_fan_mode_auto_ = supports_fan_mode_auto; + } + void set_supports_fan_mode_low(bool supports_fan_mode_low) { this->supports_fan_mode_low_ = supports_fan_mode_low; } + void set_supports_fan_mode_medium(bool supports_fan_mode_medium) { + this->supports_fan_mode_medium_ = supports_fan_mode_medium; + } + void set_supports_fan_mode_high(bool supports_fan_mode_high) { + this->supports_fan_mode_high_ = supports_fan_mode_high; + } + void set_supports_fan_mode_middle(bool supports_fan_mode_middle) { + this->supports_fan_mode_middle_ = supports_fan_mode_middle; + } + void set_supports_fan_mode_focus(bool supports_fan_mode_focus) { + this->supports_fan_mode_focus_ = supports_fan_mode_focus; + } + void set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse) { + this->supports_fan_mode_diffuse_ = supports_fan_mode_diffuse; + } + void set_supports_fan_mode_quiet(bool supports_fan_mode_quiet) { + this->supports_fan_mode_quiet_ = supports_fan_mode_quiet; + } + void set_supports_swing_mode_both(bool supports_swing_mode_both) { + this->supports_swing_mode_both_ = supports_swing_mode_both; + } + void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal) { + this->supports_swing_mode_horizontal_ = supports_swing_mode_horizontal; + } + void set_supports_swing_mode_off(bool supports_swing_mode_off) { + this->supports_swing_mode_off_ = supports_swing_mode_off; + } + void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical) { + this->supports_swing_mode_vertical_ = supports_swing_mode_vertical; + } void set_supports_dehumidification(bool supports_dehumidification); void set_supports_humidification(bool supports_humidification); - void set_supports_two_points(bool supports_two_points); + void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets); + void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } void set_custom_preset_config(std::initializer_list presets); Trigger<> *get_cool_action_trigger(); @@ -181,10 +213,10 @@ class ThermostatClimate final : public climate::Climate, public Component { Trigger<> *get_humidity_control_humidify_action_trigger(); Trigger<> *get_humidity_control_off_action_trigger(); /// Get current hysteresis values - float cool_deadband(); - float cool_overrun(); - float heat_deadband(); - float heat_overrun(); + float cool_deadband() { return this->cooling_deadband_; } + float cool_overrun() { return this->cooling_overrun_; } + float heat_deadband() { return this->heating_deadband_; } + float heat_overrun() { return this->heating_overrun_; } /// Call triggers based on updated climate states (modes/actions) void refresh(); /// Returns true if a climate action/fan mode transition is being delayed @@ -193,7 +225,7 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Returns the climate action that is being delayed (check climate_action_change_delayed(), first!) climate::ClimateAction delayed_climate_action(); /// Returns the fan mode that is locked in (check fan_mode_change_delayed(), first!) - climate::ClimateFanMode locked_fan_mode(); + climate::ClimateFanMode locked_fan_mode() { return this->prev_fan_mode_; } /// Set point and hysteresis validation bool hysteresis_valid(); // returns true if valid bool humidity_hysteresis_valid(); // returns true if valid From 78240c9a46f63fb6e7778f2b2e5720765e7a8bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:57 -0500 Subject: [PATCH 232/470] [sprinkler] Inline the trivial Sprinkler accessors (#18636) --- esphome/components/sprinkler/sprinkler.cpp | 43 --------------------- esphome/components/sprinkler/sprinkler.h | 44 +++++++++++++--------- 2 files changed, 27 insertions(+), 60 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 336123a472..2edceb76a5 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -211,8 +211,6 @@ uint32_t SprinklerValveOperator::time_remaining() { return 0; // run completed } -SprinklerState SprinklerValveOperator::state() { return this->state_; } - switch_::Switch *SprinklerValveOperator::pump_switch() { if ((this->controller_ == nullptr) || (this->valve_ == nullptr)) { return nullptr; @@ -288,11 +286,8 @@ SprinklerValveRunRequest::SprinklerValveRunRequest(size_t valve_number, uint32_t SprinklerValveOperator *valve_op) : valve_number_(valve_number), run_duration_(run_duration), valve_op_(valve_op) {} -bool SprinklerValveRunRequest::has_request() { return this->has_valve_; } bool SprinklerValveRunRequest::has_valve_operator() { return !(this->valve_op_ == nullptr); } -void SprinklerValveRunRequest::set_request_from(SprinklerValveRunRequestOrigin origin) { this->origin_ = origin; } - void SprinklerValveRunRequest::set_run_duration(uint32_t run_duration) { this->run_duration_ = run_duration; } void SprinklerValveRunRequest::set_valve(size_t valve_number) { @@ -317,8 +312,6 @@ void SprinklerValveRunRequest::reset() { uint32_t SprinklerValveRunRequest::run_duration() { return this->run_duration_; } -size_t SprinklerValveRunRequest::valve() { return this->valve_number_; } - optional SprinklerValveRunRequest::valve_as_opt() { if (this->has_valve_) { return this->valve_number_; @@ -328,8 +321,6 @@ optional SprinklerValveRunRequest::valve_as_opt() { SprinklerValveOperator *SprinklerValveRunRequest::valve_operator() { return this->valve_op_; } -SprinklerValveRunRequestOrigin SprinklerValveRunRequest::request_is_from() { return this->origin_; } - Sprinkler::Sprinkler() : Sprinkler("") {} Sprinkler::Sprinkler(const char *name) : name_(name) { // The `name` is stored for dump_config logging @@ -414,18 +405,6 @@ void Sprinkler::set_controller_main_switch(SprinklerControllerSwitch *controller this->sprinkler_turn_on_automation_->add_actions({sprinkler_resumeorstart_action_.get()}); } -void Sprinkler::set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch) { - this->auto_adv_sw_ = auto_adv_switch; -} - -void Sprinkler::set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch) { - this->queue_enable_sw_ = queue_enable_switch; -} - -void Sprinkler::set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch) { - this->reverse_sw_ = reverse_switch; -} - void Sprinkler::set_controller_standby_switch(SprinklerControllerSwitch *standby_switch) { this->standby_sw_ = standby_switch; @@ -434,14 +413,6 @@ void Sprinkler::set_controller_standby_switch(SprinklerControllerSwitch *standby this->sprinkler_standby_turn_on_automation_->add_actions({sprinkler_standby_shutdown_action_.get()}); } -void Sprinkler::set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number) { - this->multiplier_number_ = multiplier_number; -} - -void Sprinkler::set_controller_repeat_number(SprinklerControllerNumber *repeat_number) { - this->repeat_number_ = repeat_number; -} - void Sprinkler::configure_valve_switch(size_t valve_number, switch_::Switch *valve_switch, uint32_t run_duration) { if (this->is_a_valid_valve(valve_number)) { this->valve_[valve_number].valve_switch = valve_switch; @@ -498,10 +469,6 @@ void Sprinkler::set_multiplier(const optional multiplier) { call.perform(); } -void Sprinkler::set_next_prev_ignore_disabled_valves(bool ignore_disabled) { - this->next_prev_ignore_disabled_ = ignore_disabled; -} - void Sprinkler::set_pump_start_delay(uint32_t start_delay) { this->start_delay_is_valve_delay_ = false; this->start_delay_ = start_delay; @@ -522,10 +489,6 @@ void Sprinkler::set_valve_stop_delay(uint32_t stop_delay) { this->stop_delay_ = stop_delay; } -void Sprinkler::set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay) { - this->pump_switch_off_during_valve_open_delay_ = pump_switch_off_during_valve_open_delay; -} - void Sprinkler::set_valve_open_delay(const uint32_t valve_open_delay) { if (valve_open_delay > 0) { this->valve_overlap_ = false; @@ -945,8 +908,6 @@ optional Sprinkler::active_valve() { return this->active_req_.valve_as_opt(); } -optional Sprinkler::paused_valve() { return this->paused_valve_; } - optional Sprinkler::queued_valve() { if (!this->queued_valves_.empty()) { return this->queued_valves_.back().valve_number; @@ -954,10 +915,6 @@ optional Sprinkler::queued_valve() { return nullopt; } -optional Sprinkler::manual_valve() { return this->manual_valve_; } - -size_t Sprinkler::number_of_valves() { return this->valve_.size(); } - bool Sprinkler::is_a_valid_valve(const size_t valve_number) { return (valve_number < this->number_of_valves()); } bool Sprinkler::pump_in_use(switch_::Switch *pump_switch) { diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index bd610f7ad3..2499a0a591 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -124,9 +124,9 @@ class SprinklerValveOperator { void set_stop_delay(uint32_t stop_delay, bool stop_delay_is_valve_delay); void start(); void stop(); - uint32_t run_duration(); // returns the desired run duration in seconds - uint32_t time_remaining(); // returns seconds remaining (does not include stop_delay_) - SprinklerState state(); // returns the valve's state/status + uint32_t run_duration(); // returns the desired run duration in seconds + uint32_t time_remaining(); // returns seconds remaining (does not include stop_delay_) + SprinklerState state() { return this->state_; } switch_::Switch *pump_switch(); // returns this SprinklerValveOperator's pump switch protected: @@ -152,18 +152,18 @@ class SprinklerValveRunRequest { public: SprinklerValveRunRequest(); SprinklerValveRunRequest(size_t valve_number, uint32_t run_duration, SprinklerValveOperator *valve_op); - bool has_request(); + bool has_request() { return this->has_valve_; } bool has_valve_operator(); - void set_request_from(SprinklerValveRunRequestOrigin origin); + void set_request_from(SprinklerValveRunRequestOrigin origin) { this->origin_ = origin; } void set_run_duration(uint32_t run_duration); void set_valve(size_t valve_number); void set_valve_operator(SprinklerValveOperator *valve_op); void reset(); uint32_t run_duration(); - size_t valve(); + size_t valve() { return this->valve_number_; } optional valve_as_opt(); SprinklerValveOperator *valve_operator(); - SprinklerValveRunRequestOrigin request_is_from(); + SprinklerValveRunRequestOrigin request_is_from() { return this->origin_; } protected: bool has_valve_{false}; @@ -189,14 +189,20 @@ class Sprinkler final : public Component { /// configure important controller switches void set_controller_main_switch(SprinklerControllerSwitch *controller_switch); - void set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch); - void set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch); - void set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch); + void set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch) { + this->auto_adv_sw_ = auto_adv_switch; + } + void set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch) { + this->queue_enable_sw_ = queue_enable_switch; + } + void set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch) { this->reverse_sw_ = reverse_switch; } void set_controller_standby_switch(SprinklerControllerSwitch *standby_switch); /// configure important controller number components - void set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number); - void set_controller_repeat_number(SprinklerControllerNumber *repeat_number); + void set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number) { + this->multiplier_number_ = multiplier_number; + } + void set_controller_repeat_number(SprinklerControllerNumber *repeat_number) { this->repeat_number_ = repeat_number; } /// configure a valve's switch object and run duration. run_duration is time in seconds. void configure_valve_switch(size_t valve_number, switch_::Switch *valve_switch, uint32_t run_duration); @@ -214,7 +220,9 @@ class Sprinkler final : public Component { void set_multiplier(optional multiplier); /// enable/disable skipping of disabled valves by the next and previous actions - void set_next_prev_ignore_disabled_valves(bool ignore_disabled); + void set_next_prev_ignore_disabled_valves(bool ignore_disabled) { + this->next_prev_ignore_disabled_ = ignore_disabled; + } /// set how long the pump should start after the valve (when the pump is starting) void set_pump_start_delay(uint32_t start_delay); @@ -230,7 +238,9 @@ class Sprinkler final : public Component { /// if pump_switch_off_during_valve_open_delay is true, the controller will switch off the pump during the /// valve_open_delay interval - void set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay); + void set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay) { + this->pump_switch_off_during_valve_open_delay_ = pump_switch_off_during_valve_open_delay; + } /// set how long the controller should wait to open/switch on the valve after it becomes active void set_valve_open_delay(uint32_t valve_open_delay); @@ -335,17 +345,17 @@ class Sprinkler final : public Component { optional active_valve(); /// returns the number of the valve that is paused, if any. check with 'has_value()' - optional paused_valve(); + optional paused_valve() { return this->paused_valve_; } /// returns the number of the next valve in the queue, if any. check with 'has_value()' optional queued_valve(); /// returns the number of the valve that is manually selected, if any. check with 'has_value()' /// this is set by next_valve() and previous_valve() when manual_selection_delay_ > 0 - optional manual_valve(); + optional manual_valve() { return this->manual_valve_; } /// returns the number of valves the controller is configured with - size_t number_of_valves(); + size_t number_of_valves() { return this->valve_.size(); } /// returns true if valve number is valid bool is_a_valid_valve(size_t valve_number); From 47156c9a5b5c517df04bb4c943a63a407435205a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:05:39 -0500 Subject: [PATCH 233/470] [mqtt] Inline the trivial MQTT client, component and sensor accessors (#18637) --- esphome/components/mqtt/mqtt_client.cpp | 8 -------- esphome/components/mqtt/mqtt_client.h | 12 ++++++------ esphome/components/mqtt/mqtt_component.cpp | 4 ---- esphome/components/mqtt/mqtt_component.h | 4 ++-- esphome/components/mqtt/mqtt_sensor.cpp | 2 -- esphome/components/mqtt/mqtt_sensor.h | 4 ++-- 6 files changed, 10 insertions(+), 24 deletions(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index ab665e2579..1127c36dc6 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -668,9 +668,7 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // Setters void MQTTClientComponent::disable_log_message() { this->log_message_.topic = ""; } bool MQTTClientComponent::is_log_message_enabled() const { return !this->log_message_.topic.empty(); } -void MQTTClientComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void MQTTClientComponent::register_mqtt_component(MQTTComponent *component) { this->children_.push_back(component); } -void MQTTClientComponent::set_log_level(int level) { this->log_level_ = level; } void MQTTClientComponent::set_keep_alive(uint16_t keep_alive_s) { this->mqtt_backend_.set_keep_alive(keep_alive_s); } void MQTTClientComponent::set_log_message_template(MQTTMessage &&message) { this->log_message_ = std::move(message); } const MQTTDiscoveryInfo &MQTTClientComponent::get_discovery_info() const { return this->discovery_info_; } @@ -683,10 +681,6 @@ void MQTTClientComponent::set_topic_prefix(const std::string &topic_prefix, cons } } const std::string &MQTTClientComponent::get_topic_prefix() const { return this->topic_prefix_; } -void MQTTClientComponent::set_publish_nan_as_none(bool publish_nan_as_none) { - this->publish_nan_as_none_ = publish_nan_as_none; -} -bool MQTTClientComponent::is_publish_nan_as_none() const { return this->publish_nan_as_none_; } void MQTTClientComponent::disable_birth_message() { this->birth_message_.topic = ""; this->recalculate_availability_(); @@ -766,8 +760,6 @@ MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines- // MQTTMessageTrigger MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::set_qos(uint8_t qos) { this->qos_ = qos; } -void MQTTMessageTrigger::set_payload(const std::string &payload) { this->payload_ = payload; } void MQTTMessageTrigger::setup() { global_mqtt_client->subscribe( this->topic_, diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index f741be561c..fe0966e725 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -159,7 +159,7 @@ class MQTTClientComponent final : public Component { /// Manually set the topic used for logging. void set_log_message_template(MQTTMessage &&message); - void set_log_level(int level); + void set_log_level(int level) { this->log_level_ = level; } /// Get the topic used for logging. Defaults to "/debug" and the value is cached for speed. void disable_log_message(); bool is_log_message_enabled() const; @@ -241,7 +241,7 @@ class MQTTClientComponent final : public Component { void check_connected(); - void set_reboot_timeout(uint32_t reboot_timeout); + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void register_mqtt_component(MQTTComponent *component); @@ -262,8 +262,8 @@ class MQTTClientComponent final : public Component { void set_on_disconnect(mqtt_on_disconnect_callback_t &&callback); // Publish None state instead of NaN for Home Assistant - void set_publish_nan_as_none(bool publish_nan_as_none); - bool is_publish_nan_as_none() const; + void set_publish_nan_as_none(bool publish_nan_as_none) { this->publish_nan_as_none_ = publish_nan_as_none; } + bool is_publish_nan_as_none() const { return this->publish_nan_as_none_; } void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; } @@ -344,8 +344,8 @@ class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); - void set_qos(uint8_t qos); - void set_payload(const std::string &payload); + void set_qos(uint8_t qos) { this->qos_ = qos; } + void set_payload(const std::string &payload) { this->payload_ = payload; } void setup() override; void dump_config() override; float get_setup_priority() const override; diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 3bbc1cdfa3..18a759725f 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -340,10 +340,6 @@ bool MQTTComponent::send_discovery_() { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } -uint8_t MQTTComponent::get_qos() const { return this->qos_; } - -bool MQTTComponent::get_retain() const { return this->retain_; } - bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 7983e04870..b4ae624404 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -108,11 +108,11 @@ class MQTTComponent : public Component { /// Set QOS for state messages. void set_qos(uint8_t qos); - uint8_t get_qos() const; + uint8_t get_qos() const { return this->qos_; } /// Set whether state message should be retained. void set_retain(bool retain); - bool get_retain() const; + bool get_retain() const { return this->retain_; } /// Disable discovery. Sets friendly name to "". void disable_discovery(); diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index c66465dd16..1c0625d1c9 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -39,8 +39,6 @@ uint32_t MQTTSensorComponent::get_expire_after() const { return *this->expire_after_; return 0; } -void MQTTSensorComponent::set_expire_after(uint32_t expire_after) { this->expire_after_ = expire_after; } -void MQTTSensorComponent::disable_expire_after() { this->expire_after_ = 0; } void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index 1d5ee8095c..a56963d9c1 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -22,9 +22,9 @@ class MQTTSensorComponent final : public mqtt::MQTTComponent { explicit MQTTSensorComponent(sensor::Sensor *sensor); /// Setup an expiry, 0 disables it - void set_expire_after(uint32_t expire_after); + void set_expire_after(uint32_t expire_after) { this->expire_after_ = expire_after; } /// Disable Home Assistant value expiry. - void disable_expire_after(); + void disable_expire_after() { this->expire_after_ = 0; } void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; From a30238aab6de17a67d1624291db47e325935ac79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:05:51 -0500 Subject: [PATCH 234/470] [wireguard] Inline the trivial Wireguard setters (#18638) --- esphome/components/wireguard/wireguard.cpp | 21 --------------------- esphome/components/wireguard/wireguard.h | 18 +++++++++--------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index b4641894db..2f07344d3b 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -178,25 +178,6 @@ time_t Wireguard::get_latest_handshake() const { return result; } -void Wireguard::set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } -void Wireguard::set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } -void Wireguard::set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } - -#ifdef USE_BINARY_SENSOR -void Wireguard::set_status_sensor(binary_sensor::BinarySensor *sensor) { this->status_sensor_ = sensor; } -void Wireguard::set_enabled_sensor(binary_sensor::BinarySensor *sensor) { this->enabled_sensor_ = sensor; } -#endif - -#ifdef USE_SENSOR -void Wireguard::set_handshake_sensor(sensor::Sensor *sensor) { this->handshake_sensor_ = sensor; } -#endif - -#ifdef USE_TEXT_SENSOR -void Wireguard::set_address_sensor(text_sensor::TextSensor *sensor) { this->address_sensor_ = sensor; } -#endif - -void Wireguard::disable_auto_proceed() { this->proceed_allowed_ = false; } - void Wireguard::enable() { this->enabled_ = true; ESP_LOGI(TAG, "Enabled"); @@ -218,8 +199,6 @@ void Wireguard::publish_enabled_state() { #endif } -bool Wireguard::is_enabled() { return this->enabled_; } - void Wireguard::start_connection_() { if (!this->enabled_) { ESP_LOGV(TAG, "Disabled, cannot start connection"); diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index 1fda802415..c9c2feb7ae 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -63,25 +63,25 @@ class Wireguard final : public PollingComponent { /// Prevent accidental use of std::string which would dangle void set_allowed_ips(std::initializer_list> ips) = delete; - void set_keepalive(uint16_t seconds); - void set_reboot_timeout(uint32_t seconds); - void set_srctime(time::RealTimeClock *srctime); + void set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } + void set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } + void set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } #ifdef USE_BINARY_SENSOR - void set_status_sensor(binary_sensor::BinarySensor *sensor); - void set_enabled_sensor(binary_sensor::BinarySensor *sensor); + void set_status_sensor(binary_sensor::BinarySensor *sensor) { this->status_sensor_ = sensor; } + void set_enabled_sensor(binary_sensor::BinarySensor *sensor) { this->enabled_sensor_ = sensor; } #endif #ifdef USE_SENSOR - void set_handshake_sensor(sensor::Sensor *sensor); + void set_handshake_sensor(sensor::Sensor *sensor) { this->handshake_sensor_ = sensor; } #endif #ifdef USE_TEXT_SENSOR - void set_address_sensor(text_sensor::TextSensor *sensor); + void set_address_sensor(text_sensor::TextSensor *sensor) { this->address_sensor_ = sensor; } #endif /// Block the setup step until peer is connected. - void disable_auto_proceed(); + void disable_auto_proceed() { this->proceed_allowed_ = false; } /// Enable the WireGuard component. void enable(); @@ -93,7 +93,7 @@ class Wireguard final : public PollingComponent { void publish_enabled_state(); /// Return if the WireGuard component is or is not enabled. - bool is_enabled(); + bool is_enabled() { return this->enabled_; } bool is_peer_up() const; time_t get_latest_handshake() const; From b83ce91528c4c99043e0dee42b0e28ce24375c0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:03 -0500 Subject: [PATCH 235/470] [display] Inline the trivial DisplayPage setters and page navigation helpers (#18639) --- esphome/components/display/display.cpp | 6 ------ esphome/components/display/display.h | 9 ++++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 115adf503a..c2d45dbb60 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -685,9 +685,6 @@ void Display::show_page(DisplayPage *page) { } } -void Display::show_next_page() { this->page_->show_next(); } -void Display::show_prev_page() { this->page_->show_prev(); } - void Display::do_update_() { if (this->auto_clear_enabled_) { this->clear(); @@ -892,9 +889,6 @@ void DisplayPage::show_prev() { this->prev_->show(); } -void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; } -void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; } -void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &DisplayPage::get_writer() const { return this->writer_; } const LogString *text_align_to_string(TextAlign textalign) { diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 3a136937f6..a9ffda422d 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -802,9 +802,9 @@ class DisplayPage final { void show(); void show_next(); void show_prev(); - void set_parent(Display *parent); - void set_prev(DisplayPage *prev); - void set_next(DisplayPage *next); + void set_parent(Display *parent) { this->parent_ = parent; } + void set_prev(DisplayPage *prev) { this->prev_ = prev; } + void set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &get_writer() const; protected: @@ -814,6 +814,9 @@ class DisplayPage final { DisplayPage *next_{nullptr}; }; +inline void Display::show_next_page() { this->page_->show_next(); } +inline void Display::show_prev_page() { this->page_->show_prev(); } + template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) From a63c3bc0c7f9eab2b08def454b89223480fcf9ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:31 -0500 Subject: [PATCH 236/470] [valve] Inline the trivial Valve and ValveCall accessors (#18641) --- esphome/components/valve/valve.cpp | 7 ------- esphome/components/valve/valve.h | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 8fccd1e6d6..d8fb18b1b7 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -120,10 +120,6 @@ ValveCall &ValveCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool ValveCall::get_stop() const { return this->stop_; } - -ValveCall Valve::make_call() { return {this}; } - void Valve::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); @@ -162,9 +158,6 @@ optional Valve::restore_state_() { return recovered; } -bool Valve::is_fully_open() const { return this->position == VALVE_OPEN; } -bool Valve::is_fully_closed() const { return this->position == VALVE_CLOSED; } - ValveCall ValveRestoreState::to_call(Valve *valve) { auto call = valve->make_call(); call.set_position(this->position); diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index c6cdf07096..183680e5e4 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -47,7 +47,7 @@ class ValveCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_toggle() const; protected: @@ -114,7 +114,7 @@ class Valve : public EntityBase { float position; /// Construct a new valve call used to control the valve. - ValveCall make_call(); + ValveCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -130,9 +130,9 @@ class Valve : public EntityBase { virtual ValveTraits get_traits() = 0; /// Helper method to check if the valve is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == VALVE_OPEN; } /// Helper method to check if the valve is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == VALVE_CLOSED; } protected: friend ValveCall; From 435d5226838d8f2818d637f1a284f4f85c214295 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:47 -0500 Subject: [PATCH 237/470] [text] Inline the trivial Text publish_state forwarding overloads (#18642) --- esphome/components/text/text.cpp | 4 ---- esphome/components/text/text.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 032ea468e6..a1df6286c7 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -8,10 +8,6 @@ namespace esphome::text { static const char *const TAG = "text"; -void Text::publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } - -void Text::publish_state(const char *state) { this->publish_state(state, strlen(state)); } - void Text::publish_state(const char *state, size_t len) { this->set_has_state(true); // Only assign if changed to avoid heap allocation diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index eb6a68f998..54afb8db8f 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -23,8 +23,8 @@ class Text : public EntityBase { std::string state; TextTraits traits; - void publish_state(const std::string &state); - void publish_state(const char *state); + void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } + void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); /// Instantiate a TextCall object to modify this text component's state. From 01ad424d12bc6c376e695084c078e9cb8d5c54fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:03 -0500 Subject: [PATCH 238/470] [datetime] Inline the trivial make_call helpers (#18643) --- esphome/components/datetime/date_entity.cpp | 2 -- esphome/components/datetime/date_entity.h | 2 ++ esphome/components/datetime/datetime_entity.cpp | 2 -- esphome/components/datetime/datetime_entity.h | 2 ++ esphome/components/datetime/time_entity.cpp | 2 -- esphome/components/datetime/time_entity.h | 2 ++ 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 997aec3f69..b99b89259f 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -37,8 +37,6 @@ void DateEntity::publish_state() { #endif } -DateCall DateEntity::make_call() { return DateCall(this); } - void DateCall::validate_() { if (this->year_.has_value() && (this->year_ < 1970 || this->year_ > 3000)) { ESP_LOGE(TAG, "Year must be between 1970 and 3000"); diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 9b86c12228..93ce1411f8 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -96,6 +96,8 @@ class DateCall { optional day_; }; +inline DateCall DateEntity::make_call() { return DateCall(this); } + template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index a8e00d6eb3..8f180fd081 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -53,8 +53,6 @@ void DateTimeEntity::publish_state() { #endif } -DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } - ESPTime DateTimeEntity::state_as_esptime() const { ESPTime obj; obj.year = this->year_; diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 159e4ccc6f..fec620b5ba 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,6 +121,8 @@ class DateTimeCall { optional second_; }; +inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } + template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index 1cc9eaf2fb..da4c9eb31e 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -33,8 +33,6 @@ void TimeEntity::publish_state() { #endif } -TimeCall TimeEntity::make_call() { return TimeCall(this); } - void TimeCall::validate_() { if (this->hour_.has_value() && this->hour_ > 23) { ESP_LOGE(TAG, "Hour must be between 0 and 23"); diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 643f4bd176..736e26f4a7 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,6 +98,8 @@ class TimeCall { optional second_; }; +inline TimeCall TimeEntity::make_call() { return TimeCall(this); } + template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) From f24b731f9510815fe164e975b7e4a4f4605cd45e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:14 -0500 Subject: [PATCH 239/470] [infrared] Inline the trivial make_call helper (#18644) --- esphome/components/infrared/infrared.cpp | 2 -- esphome/components/infrared/infrared.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b97995a96..5a909738c6 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -75,8 +75,6 @@ void Infrared::dump_config() { YESNO(this->traits_.get_supports_receiver())); } -InfraredCall Infrared::make_call() { return InfraredCall(this); } - void Infrared::control(const InfraredCall &call) { if (this->transmitter_ == nullptr) { ESP_LOGW(TAG, "No transmitter configured"); diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index 6d91c97cce..b6863e37ce 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -134,7 +134,7 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote const InfraredTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - InfraredCall make_call(); + InfraredCall make_call() { return InfraredCall(this); } /// Get capability flags for this infrared instance uint32_t get_capability_flags() const; From f0651e5c9b2ae24c33256819dd78eedce73c45a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:34 -0500 Subject: [PATCH 240/470] [radio_frequency] Inline the trivial make_call helper (#18645) --- esphome/components/radio_frequency/radio_frequency.cpp | 2 -- esphome/components/radio_frequency/radio_frequency.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 3e0a905737..61e7feb9af 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -81,8 +81,6 @@ void RadioFrequency::dump_config() { } } -RadioFrequencyCall RadioFrequency::make_call() { return RadioFrequencyCall(this); } - uint32_t RadioFrequency::get_capability_flags() const { uint32_t flags = 0; if (this->traits_.get_supports_transmitter()) diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 7dfd2dd77e..8782c255f0 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -157,7 +157,7 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: const RadioFrequencyTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - RadioFrequencyCall make_call(); + RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } /// Get capability flags for this radio frequency instance uint32_t get_capability_flags() const; From cd536817876caff90c750fa9e17b2bdbb181034f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:51:39 -0500 Subject: [PATCH 241/470] [wifi] Inline the remaining trivial WiFiAP and WiFiComponent accessors (#18617) --- esphome/components/wifi/wifi_component.cpp | 38 ------------------ esphome/components/wifi/wifi_component.h | 46 +++++++++++----------- 2 files changed, 24 insertions(+), 60 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ed5fc9094..b8a31f97a3 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -618,8 +618,6 @@ static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) { } #endif -float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; } - void WiFiComponent::setup() { this->wifi_pre_setup_(); @@ -931,10 +929,6 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } -#ifdef USE_WIFI_11KV_SUPPORT -void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } -void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; } -#endif network::IPAddresses WiFiComponent::get_ip_addresses() { if (this->has_sta()) return this->wifi_sta_ip_addresses(); @@ -1327,8 +1321,6 @@ void WiFiComponent::disable() { this->wifi_mode_(false, false); } -bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } - void WiFiComponent::start_scanning() { this->action_started_ = millis(); ESP_LOGD(TAG, "Starting scan"); @@ -2196,7 +2188,6 @@ void WiFiComponent::retry_connect() { } } -void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) @@ -2204,8 +2195,6 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } -void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; } - bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2324,33 +2313,6 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif -void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } -void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); } -void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } -void WiFiAP::clear_bssid() { this->bssid_ = {}; } -void WiFiAP::set_password(const std::string &password) { - this->password_ = CompactString(password.c_str(), password.size()); -} -void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); } -#ifdef USE_WIFI_WPA2_EAP -void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } -#endif -void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; } -void WiFiAP::clear_channel() { this->channel_ = 0; } -#ifdef USE_WIFI_MANUAL_IP -void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } -#endif -void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } -const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } -bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } -#ifdef USE_WIFI_WPA2_EAP -const optional &WiFiAP::get_eap() const { return this->eap_; } -#endif -#ifdef USE_WIFI_MANUAL_IP -const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } -#endif -bool WiFiAP::get_hidden() const { return this->hidden_; } - WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c54fbc004b..07d4ff23c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #ifdef USE_LIBRETINY @@ -261,38 +262,38 @@ class WiFiAP { friend class WiFiScanResult; public: - void set_ssid(const std::string &ssid); - void set_ssid(const char *ssid); + void set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } + void set_ssid(const char *ssid) { this->set_ssid(StringRef(ssid)); } void set_ssid(StringRef ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } - void set_bssid(const bssid_t &bssid); - void clear_bssid(); - void set_password(const std::string &password); - void set_password(const char *password); + void set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } + void clear_bssid() { this->bssid_ = {}; } + void set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); } + void set_password(const char *password) { this->set_password(StringRef(password)); } void set_password(StringRef password) { this->password_ = CompactString(password.c_str(), password.size()); } #ifdef USE_WIFI_WPA2_EAP - void set_eap(optional eap_auth); + void set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif // USE_WIFI_WPA2_EAP - void set_channel(uint8_t channel); - void clear_channel(); + void set_channel(uint8_t channel) { this->channel_ = channel; } + void clear_channel() { this->channel_ = 0; } void set_priority(int8_t priority) { priority_ = priority; } #ifdef USE_WIFI_MANUAL_IP - void set_manual_ip(optional manual_ip); + void set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } #endif - void set_hidden(bool hidden); + void set_hidden(bool hidden) { this->hidden_ = hidden; } StringRef get_ssid() const { return this->ssid_.ref(); } StringRef get_password() const { return this->password_.ref(); } - const bssid_t &get_bssid() const; - bool has_bssid() const; + const bssid_t &get_bssid() const { return this->bssid_; } + bool has_bssid() const { return this->bssid_ != bssid_t{}; } #ifdef USE_WIFI_WPA2_EAP - const optional &get_eap() const; + const optional &get_eap() const { return this->eap_; } #endif // USE_WIFI_WPA2_EAP uint8_t get_channel() const { return this->channel_; } bool has_channel() const { return this->channel_ != 0; } int8_t get_priority() const { return priority_; } #ifdef USE_WIFI_MANUAL_IP - const optional &get_manual_ip() const; + const optional &get_manual_ip() const { return this->manual_ip_; } #endif - bool get_hidden() const; + bool get_hidden() const { return this->hidden_; } protected: CompactString ssid_; @@ -442,6 +443,7 @@ class WiFiComponent final : public Component { void set_sta(const WiFiAP &ap); // Returns a copy of the currently selected AP configuration WiFiAP get_sta() const; + // init_sta/add_sta kept out of line: inlining them into the generated setup() grows flash void init_sta(size_t count); void add_sta(const WiFiAP &ap); void clear_sta(); @@ -461,7 +463,7 @@ class WiFiComponent final : public Component { void enable(); void disable(); - bool is_disabled(); + bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } void start_scanning(); void check_scanning_finished(); void start_connecting(const WiFiAP &ap); @@ -472,7 +474,7 @@ class WiFiComponent final : public Component { void retry_connect(); - void set_reboot_timeout(uint32_t reboot_timeout); + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool is_connected() const { return this->connected_; } @@ -492,7 +494,7 @@ class WiFiComponent final : public Component { void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; } #endif - void set_passive_scan(bool passive); + void set_passive_scan(bool passive) { this->passive_scan_ = passive; } void save_wifi_sta(const std::string &ssid, const std::string &password); void save_wifi_sta(const char *ssid, const char *password); @@ -506,7 +508,7 @@ class WiFiComponent final : public Component { void dump_config() override; void restart_adapter(); /// WIFI setup_priority. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::WIFI; } /// Reconnect WiFi if required. void loop() override; @@ -515,8 +517,8 @@ class WiFiComponent final : public Component { bool is_ap_active() const { return this->ap_started_; } #ifdef USE_WIFI_11KV_SUPPORT - void set_btm(bool btm); - void set_rrm(bool rrm); + void set_btm(bool btm) { this->btm_ = btm; } + void set_rrm(bool rrm) { this->rrm_ = rrm; } #endif network::IPAddress get_dns_address(int num); From 5b3a6c05bf40a5776da603d422495e345363da3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:59:47 -0500 Subject: [PATCH 242/470] [core] Remove deprecated esp_log_vprintf_ flash-string overload (#18377) --- esphome/core/log.cpp | 10 ---------- esphome/core/log.h | 5 ----- 2 files changed, 15 deletions(-) diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 0da457adec..9fcddfeff6 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -60,16 +60,6 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form #endif } -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Remove before 2026.9.0 -void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { -#ifdef USE_LOGGER - ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); - logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); -#endif -} -#endif - #ifdef USE_ESP32 int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER diff --git a/esphome/core/log.h b/esphome/core/log.h index 72e06cabac..272e516808 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -68,11 +68,6 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, . void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Remove before 2026.9.0 -__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_( - int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); -#endif #if defined(USE_ESP32) int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif From ab45ab316a0190cf898e436a345e2a20a5f15c42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:02 -0500 Subject: [PATCH 243/470] [core] Remove deprecated entity_base getters (#18375) --- esphome/core/entity_base.cpp | 40 ------------------------------- esphome/core/entity_base.h | 46 ------------------------------------ 2 files changed, 86 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index fc6ac503b5..21a5fc3706 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::spandevice_class_idx_)); -#else - return StringRef(entity_device_class_lookup(0)); -#endif -} -std::string EntityBase::get_device_class() const { -#ifdef USE_ENTITY_DEVICE_CLASS - return std::string(entity_device_class_lookup(this->device_class_idx_)); -#else - return std::string(entity_device_class_lookup(0)); -#endif -} -#endif // !USE_ESP8266 - // Entity unit of measurement (from index) StringRef EntityBase::get_unit_of_measurement_ref() const { #ifdef USE_ENTITY_UNIT_OF_MEASUREMENT @@ -106,10 +88,6 @@ StringRef EntityBase::get_unit_of_measurement_ref() const { return StringRef(entity_uom_lookup(0)); #endif } -std::string EntityBase::get_unit_of_measurement() const { - return std::string(this->get_unit_of_measurement_ref().c_str()); -} - // Entity icon — buffer-based API for PROGMEM safety on ESP8266 const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { #ifdef USE_ENTITY_ICON @@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::spanicon_idx_)); -#else - return StringRef(entity_icon_lookup(0)); -#endif -} -std::string EntityBase::get_icon() const { -#ifdef USE_ENTITY_ICON - return std::string(entity_icon_lookup(this->icon_idx_)); -#else - return std::string(entity_icon_lookup(0)); -#endif -} -#endif // !USE_ESP8266 - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5f2e173d8d..f38e30bf52 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -109,60 +109,14 @@ class EntityBase { // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_device_class_to(std::span buffer) const; -#ifdef USE_ESP8266 - // On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed - // directly as const char*. Use get_device_class_to() with a stack buffer instead. - template StringRef get_device_class_ref() const { - static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). " - "Use get_device_class_to() with a stack buffer."); - return StringRef(""); - } - template std::string get_device_class() const { - static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). " - "Use get_device_class_to() with a stack buffer."); - return ""; - } -#else - // Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM. - ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - StringRef get_device_class_ref() const; - ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - std::string get_device_class() const; -#endif // Get unit of measurement as StringRef (from packed index) StringRef get_unit_of_measurement_ref() const; - /// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref()) - ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " - "removed in ESPHome 2026.9.0", - "2026.3.0") - std::string get_unit_of_measurement() const; // Get this entity's icon into a stack buffer. // On ESP32: returns pointer to PROGMEM string directly (buffer unused). // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_icon_to(std::span buffer) const; -#ifdef USE_ESP8266 - // On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed - // directly as const char*. Use get_icon_to() with a stack buffer instead. - template StringRef get_icon_ref() const { - static_assert(sizeof(T) == 0, - "get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); - return StringRef(""); - } - template std::string get_icon() const { - static_assert(sizeof(T) == 0, - "get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); - return ""; - } -#else - // Deprecated: use get_icon_to() instead. Icons are in PROGMEM. - ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - StringRef get_icon_ref() const; - ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - std::string get_icon() const; -#endif - #ifdef USE_DEVICES // Get this entity's device id uint32_t get_device_id() const { From b115813fbe2880a3e7ffbc2e4725e208c589d97b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:17 -0500 Subject: [PATCH 244/470] [esp32] Report abort and task watchdog panics correctly in crash handler (#18575) --- esphome/components/esp32/crash_handler.cpp | 61 +++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b61dad7386..6f65243aaa 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. static constexpr uint32_t CRASH_DATA_VERSION = 4; +#if CONFIG_IDF_TARGET_ARCH_XTENSA +// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's +// cause/vaddr slots were never written (not a real exception frame). +static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +// Synchronous mcause exception codes are small and have no interrupt bit; +// anything else in a non-pseudo record is a stale slot. +static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32; +#endif struct RawCrashData { uint32_t version; uint32_t magic; @@ -198,10 +207,28 @@ void crash_handler_clear() { s_raw_crash_data.magic = 0; } +// Whether the cause slot was written by a real exception frame. +static bool cause_slot_was_written() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT; +#else + return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT; +#endif +} + // Look up the exception cause as a human-readable string. // Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays // not exposed via any public API. static const char *get_exception_reason() { + uint8_t exception = s_raw_crash_data.exception; + if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) { + // Abort-class panics carry no cause register + return nullptr; + } + if (!cause_slot_was_written()) { + // Garbage from old-build or corrupt records; report just the type + return nullptr; + } #if CONFIG_IDF_TARGET_ARCH_XTENSA if (s_raw_crash_data.pseudo_excause) { // SoC-level panic: watchdog, cache error, etc. @@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL"; static const char *const FAULT_ADDR_REG_LOWER = "mtval"; #endif -// Whether the fault address is meaningful — real CPU faults only, not -// aborts/watchdogs or SoC-level pseudo exceptions. +// Whether the fault address is meaningful: real CPU faults with a validly +// written frame only. static bool has_fault_addr() { - return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; + return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause && + cause_slot_was_written(); } // The record was captured by a different firmware build (it survives soft @@ -458,6 +486,10 @@ void crash_handler_log() { // into NOINIT memory before the normal panic handler runs. // extern "C" { +// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an +// abort; weak so builds without the task watchdog still link. +extern bool g_twdt_isr __attribute__((weak)); + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) // Names are mandated by the --wrap linker mechanism extern void __real_esp_panic_handler(panic_info_t *info); @@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; s_raw_crash_data.crashed_core = (uint8_t) info->core; + if (g_panic_abort) { + // IDF reclassifies to ABORT only inside esp_panic_handler(), after this + // wrapper captured info->exception; correct it here. TWDT is our own + // distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is + // not stored; the symbolized backtrace already identifies the site. + bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr; + s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT); + } // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot s_raw_crash_data.cause = 0; s_raw_crash_data.fault_addr = 0; @@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; - s_raw_crash_data.cause = xt_frame->exccause; - s_raw_crash_data.fault_addr = xt_frame->excvaddr; + if (!g_panic_abort) { + // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots + // never wrote them and abort() traps describe only the synthetic trap. + s_raw_crash_data.cause = xt_frame->exccause; + s_raw_crash_data.fault_addr = xt_frame->excvaddr; + } s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } @@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // RISC-V: capture MEPC + RA, then scan stack for code addresses if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; - s_raw_crash_data.cause = rv_frame->mcause; - s_raw_crash_data.fault_addr = rv_frame->mtval; + if (!g_panic_abort) { + // See the Xtensa branch: abort-class frames carry no valid cause/vaddr. + s_raw_crash_data.cause = rv_frame->mcause; + s_raw_crash_data.fault_addr = rv_frame->mtval; + } s_raw_crash_data.backtrace_count = capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } From f3cdefce210b9b418da3487ff57c1421bb779137 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:38 -0500 Subject: [PATCH 245/470] [wifi] Remove deprecated wifi_ssid() (#18378) --- esphome/components/wifi/wifi_component.h | 3 --- esphome/components/wifi/wifi_component_esp8266.cpp | 10 ---------- esphome/components/wifi/wifi_component_esp_idf.cpp | 12 ------------ esphome/components/wifi/wifi_component_libretiny.cpp | 1 - esphome/components/wifi/wifi_component_pico_w.cpp | 1 - 5 files changed, 27 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 07d4ff23c6..ada7be4ba4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -552,9 +552,6 @@ class WiFiComponent final : public Component { void set_sta_priority(bssid_t bssid, int8_t priority); network::IPAddresses wifi_sta_ip_addresses(); - // Remove before 2026.9.0 - ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0") - std::string wifi_ssid(); /// Write SSID to buffer without heap allocation. /// Returns pointer to buffer, or empty string if not connected. const char *wifi_ssid_to(std::span buffer); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index acaa94b13c..005d655d88 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -944,16 +944,6 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { - struct station_config conf {}; - if (!wifi_station_get_config(&conf)) { - return ""; - } - // conf.ssid is uint8[32], not null-terminated if full - auto *ssid_s = reinterpret_cast(conf.ssid); - size_t len = strnlen(ssid_s, sizeof(conf.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { struct station_config conf {}; if (!wifi_station_get_config(&conf)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 24cb060edb..32d46887b6 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1237,18 +1237,6 @@ bssid_t WiFiComponent::wifi_bssid() { std::copy(info.bssid, info.bssid + 6, bssid.begin()); return bssid; } -std::string WiFiComponent::wifi_ssid() { - wifi_ap_record_t info{}; - esp_err_t err = esp_wifi_sta_get_ap_info(&info); - if (err != ESP_OK) { - // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) - ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); - return ""; - } - auto *ssid_s = reinterpret_cast(info.ssid); - size_t len = strnlen(ssid_s, sizeof(info.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { wifi_ap_record_t info{}; esp_err_t err = esp_wifi_sta_get_ap_info(&info); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 66c397a8ad..e3c08416e8 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -762,7 +762,6 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } const char *WiFiComponent::wifi_ssid_to(std::span buffer) { #ifdef USE_BK72XX LinkStatusTypeDef link_status{}; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 69af9e9a4e..325bcf2652 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -265,7 +265,6 @@ bssid_t WiFiComponent::wifi_bssid() { bssid[i] = raw_bssid[i]; return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } const char *WiFiComponent::wifi_ssid_to(std::span buffer) { // TODO: Find direct CYW43 API to avoid Arduino String allocation String ssid = WiFi.SSID(); From 8899713ef97229f881ab8694802ef03a9290c65a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:53 -0500 Subject: [PATCH 246/470] [core] Remove deprecated gamma_correct and gamma_uncorrect (#18376) --- esphome/core/helpers.cpp | 17 ----------------- esphome/core/helpers.h | 9 --------- 2 files changed, 26 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a276020be4..ded8051df8 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector // Colors -float gamma_correct(float value, float gamma) { - if (value <= 0.0f) - return 0.0f; - if (gamma <= 0.0f) - return value; - - return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0 -} -float gamma_uncorrect(float value, float gamma) { - if (value <= 0.0f) - return 0.0f; - if (gamma <= 0.0f) - return value; - - return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0 -} - void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) { float max_color_value = std::max({red, green, blue}); float min_color_value = std::min({red, green, blue}); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5a9c120b84..b13d92ccce 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector /// @name Colors ///@{ -/// Applies gamma correction of \p gamma to \p value. -// Remove before 2026.9.0 -ESPDEPRECATED("Use LightState::gamma_correct_lut() instead. Removed in 2026.9.0.", "2026.3.0") -float gamma_correct(float value, float gamma); -/// Reverts gamma correction of \p gamma to \p value. -// Remove before 2026.9.0 -ESPDEPRECATED("Use LightState::gamma_uncorrect_lut() instead. Removed in 2026.9.0.", "2026.3.0") -float gamma_uncorrect(float value, float gamma); - /// Convert \p red, \p green and \p blue (all 0-1) values to \p hue (0-360), \p saturation (0-1) and \p value (0-1). void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value); /// Convert \p hue (0-360), \p saturation (0-1) and \p value (0-1) to \p red, \p green and \p blue (all 0-1). From 160d8b8f0ccdb5362daf2b144131fe5e35355991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:05 -0500 Subject: [PATCH 247/470] [web_server_idf] Remove deprecated AsyncWebServerRequest::url() (#18382) --- esphome/components/web_server_idf/web_server_idf.h | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index baa55898bb..6469b4c564 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -117,12 +117,6 @@ class AsyncWebServerRequest { /// Write URL (without query string) to buffer, returns StringRef pointing to buffer. /// URL is decoded (e.g., %20 -> space). StringRef url_to(std::span buffer) const; - // Remove before 2026.9.0 - ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0") - std::string url() const { - char buffer[URL_BUF_SIZE]; - return std::string(this->url_to(buffer)); - } // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } From b2440cb655f794ec7a2d3e83f87fea0d99fc8ed8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:22 -0500 Subject: [PATCH 248/470] [modbus] Remove deprecated waiting_for_response() (#18381) --- esphome/components/modbus/modbus.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index bb303c43a8..e5cbba88ec 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -618,9 +618,6 @@ class ModbusClientDevice { inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !this->ready_for_immediate_send(); } bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } protected: From 02da5c6484ecc478f80617ed956c9337dd42f653 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:41 -0500 Subject: [PATCH 249/470] [ethernet] Remove deprecated get_eth_mac_address_pretty() (#18379) --- esphome/components/ethernet/ethernet_component.h | 3 --- esphome/components/ethernet/ethernet_component_esp32.cpp | 5 ----- esphome/components/ethernet/ethernet_component_rp2.cpp | 5 ----- 3 files changed, 13 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2da070b5e0..1482e7a828 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -159,9 +159,6 @@ class EthernetComponent final : public Component { const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); - // Remove before 2026.9.0 - ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") - std::string get_eth_mac_address_pretty(); const char *get_eth_mac_address_pretty_into_buffer(std::span buf); eth_duplex_t get_duplex_mode(); eth_speed_t get_link_speed(); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 4af2d5f93c..069478e70c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -928,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 7f4db4fab7..94d84cc891 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { } } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; From d1f065671eab8fcfd25dae8106beedbcc0499452 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:21:04 -0500 Subject: [PATCH 250/470] [http_request] Abort OTA backend when update fails before first write (#18581) --- .../http_request/ota/ota_http_request.cpp | 17 +++++++++-------- .../http_request/ota/ota_http_request.h | 3 +-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8893b96c65..7e7594c3c3 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() { } } -void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container) { - if (this->update_started_) { +void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, + bool abort_backend) { + if (abort_backend) { ESP_LOGV(TAG, "Aborting OTA backend"); backend->abort(); } @@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() { auto error_code = backend->begin(container->content_length); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "backend->begin error: %d", error_code); - this->cleanup_(std::move(backend), container); + // Nothing to abort: begin() failed, so no OTA handle was opened + this->cleanup_(std::move(backend), container, /*abort_backend=*/false); return error_code; } @@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { } else { ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error); } - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return OTA_CONNECTION_ERROR; } @@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() { md5_receive.add(buf, bufsize_or_error); // write bytes to OTA backend - this->update_started_ = true; error_code = backend->write(buf, bufsize_or_error); if (error_code != ota::OTA_RESPONSE_OK) { // error code explanation available at // https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code, container->get_bytes_read() - bufsize_or_error, container->content_length); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } } @@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { this->md5_computed_ = md5_receive_str; if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) { ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str()); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH; } else { backend->set_update_md5(md5_receive_str); @@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { error_code = backend->end(); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index a706331d9a..9bb748f175 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< void flash(); protected: - void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container); + void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, bool abort_backend); uint8_t do_ota_(); std::string get_url_with_auth_(const std::string &url); bool http_get_md5_(); @@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< std::string username_{}; std::string url_{}; int status_ = -1; - bool update_started_ = false; static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size }; From e697a40fda84887373d1ab3ba77bb3af64da8560 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:06 -0500 Subject: [PATCH 251/470] [core] Register the OTA component in dummy_main like its siblings (#18666) --- tests/dummy_main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index 6fa0c08aa3..228d54ef01 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -29,6 +29,7 @@ void setup() { auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT ota->set_port(8266); + App.register_component_(ota); App.setup(); } From 33484108a982678208a9619d03e67d691df49f03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:24 -0500 Subject: [PATCH 252/470] [core] Replace a damaged existing file in write_file_if_changed (#18665) --- esphome/helpers.py | 13 ++++++++++++- tests/unit_tests/test_helpers.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 9b2a461ccd..7aa1a9a88c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -552,7 +552,18 @@ def write_file_if_changed(path: Path, text: str) -> bool: """ src_content = None if path.is_file(): - src_content = read_file(path) + try: + src_content = path.read_text(encoding="utf-8") + except UnicodeDecodeError as err: + # Replace a damaged file rather than abort the regeneration that + # fixes it; an OSError may hide an intact file, so it still raises + _LOGGER.warning("Replacing damaged file %s: %s", path, err) + with suppress(OSError): + path.unlink(missing_ok=True) + except OSError as err: + from esphome.core import EsphomeError + + raise EsphomeError(f"Error reading file {path}: {err}") from err if src_content == text: return False write_file(path, text) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 6e00e5b80f..eaa7d5a8dc 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -253,6 +253,31 @@ class Test_write_file_if_changed: assert dst.read_text() == text + def test_damaged_existing_file_is_replaced( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ): + """A non-UTF-8 existing file is logged and overwritten.""" + dst = tmp_path / "generated.txt" + dst.write_bytes(b"\xff\xfe") + + assert helpers.write_file_if_changed(dst, "fresh content") is True + + assert dst.read_text(encoding="utf-8") == "fresh content" + assert "Replacing damaged file" in caplog.text + + def test_unreadable_existing_file_still_raises(self, tmp_path: Path): + """An OSError on the comparison read still raises EsphomeError.""" + dst = tmp_path / "generated.txt" + dst.write_text("intact") + + with ( + patch.object(Path, "read_text", side_effect=OSError("permission denied")), + pytest.raises(EsphomeError, match="Error reading file"), + ): + helpers.write_file_if_changed(dst, "fresh content") + + assert dst.exists() + def test_dst_does_not_exist(self, tmp_path: Path): text = "A files are unique.\n" dst = tmp_path / "file-a.txt" From e7574a574b6d5e2303df20edb73e64474ef23113 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:48 -0500 Subject: [PATCH 253/470] [ota] Restore lazy flash erase for ESP32 OTA with 64 KiB block erase (#18580) --- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/ota/ota_backend.h | 13 +++ .../components/ota/ota_backend_esp_idf.cpp | 86 +++++++++++++++---- esphome/components/ota/ota_backend_esp_idf.h | 19 +++- .../components/ota/ota_bootloader_esp_idf.cpp | 11 ++- .../components/ota/ota_signature_esp_idf.cpp | 2 +- tests/components/ota/test_erase_ahead.cpp | 41 +++++++++ 7 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 tests/components/ota/test_erase_ahead.cpp diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 9cbb25b373..74f84b71fb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -398,7 +398,7 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // begin() may block for a few seconds while it locks flash. + // begin() returns quickly; flash sectors are erased incrementally during write(). error_code = this->backend_->begin(ota_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index aa93df60a5..1c24fc320a 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -66,6 +66,19 @@ enum OTAResponseTypes { */ bool version_is_older(const char *candidate, const char *reference); +// 64 KiB flash block; the erase granularity the ESP-IDF backend erases ahead with. +static constexpr size_t OTA_BLOCK_ERASE_SIZE = 64 * 1024; + +/** Target erased watermark for lazy block erase-ahead. + * + * Rounds the write end offset up to a block boundary, clamped to the partition + * size. Platform-independent so the arithmetic is host-testable. + */ +constexpr size_t next_erase_end(size_t write_end, size_t partition_size) { + const size_t rounded = (write_end + OTA_BLOCK_ERASE_SIZE - 1) & ~(OTA_BLOCK_ERASE_SIZE - 1); + return rounded < partition_size ? rounded : partition_size; +} + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index eb23ad82dd..f33f37bbeb 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -7,7 +7,7 @@ #include "esphome/core/log.h" #include -#include +#include #include #ifdef USE_OTA_DOWNGRADE_PROTECTION #include @@ -60,27 +60,38 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - // 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; + // Both lazy-erase paths below replace esp_ota_begin()'s blocking full erase. + // Size check replaces the one that erase performed (0 = unknown size, + // e.g. web_server uploads). + if (image_size != 0 && image_size > this->partition_->size) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } - 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_); + this->written_ = 0; + esp_err_t err; +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + this->erased_end_ = 0; + // Unlike esp_ota_begin(), esp_ota_resume() does not reject a running app in + // ESP_OTA_IMG_PENDING_VERIFY; that state is unreachable here because the app + // was marked valid at boot (esp32/hal.cpp) or just above under USE_OTA_ROLLBACK. + // erase_size 0 (!= OTA_WITH_SEQUENTIAL_WRITES) means no erase; erase_ahead_() handles it + err = esp_ota_resume(this->partition_, 0, 0, &this->update_handle_); +#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // esp_ota_begin() does this on IDF 5.5+; esp_ota_resume() does not. Prevents + // booting a half-written slot after a crash mid-OTA. Not available on the + // 5.3.3/5.4.2 backports, whose esp_ota_begin() did not invalidate either. + if (err == ESP_OK) { + esp_ota_invalidate_inactive_ota_data_slot(); + } +#endif +#else + err = esp_ota_begin(this->partition_, OTA_WITH_SEQUENTIAL_WRITES, &this->update_handle_); +#endif if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + ESP_LOGE(TAG, "OTA begin failed (err=0x%X)", err); esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - if (err == ESP_ERR_INVALID_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) { // This error appears with 1 factory and 1 ota partition @@ -120,6 +131,17 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } +#endif + // Overflow can only happen on unknown-size uploads (web_server); known + // sizes were rejected in begin(). + if (this->written_ + len > this->partition_->size) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + OTAResponseTypes erase_result = this->erase_ahead_(len); + if (erase_result != OTA_RESPONSE_OK) { + return erase_result; + } #endif esp_err_t err = esp_ota_write(this->update_handle_, data, len); this->md5_.add(data, len); @@ -127,14 +149,40 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); if (err == ESP_ERR_OTA_VALIDATE_FAILED) { return OTA_RESPONSE_ERROR_MAGIC; + } else if (err == ESP_ERR_INVALID_SIZE) { + // Sequential-writes fallback: IDF's lazy erase reports overflow here + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } return OTA_RESPONSE_ERROR_UNKNOWN; } + this->written_ += len; return OTA_RESPONSE_OK; } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD +OTAResponseTypes IDFOTABackend::erase_ahead_(size_t len) { + const size_t end = this->written_ + len; + if (this->erased_end_ >= end) { + return OTA_RESPONSE_OK; + } + // Round up to a block boundary, clamped to the partition end; IDF splits the + // range into 64 KiB block erases where aligned, sector erases elsewhere. + const size_t erase_to = next_erase_end(end, this->partition_->size); + // A block erase is one uninterruptible flash op (typically ~150 ms, seconds + // on aged flash) and the transfer loop may not have fed the WDT for ~1s. + watchdog::WatchdogManager watchdog(15000); + esp_err_t err = esp_partition_erase_range(this->partition_, this->erased_end_, erase_to - this->erased_end_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_erase_range failed (err=0x%X)", err); + return err == ESP_ERR_INVALID_SIZE ? OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE : OTA_RESPONSE_ERROR_WRITING_FLASH; + } + this->erased_end_ = erase_to; + return OTA_RESPONSE_OK; +} +#endif + OTAResponseTypes IDFOTABackend::end() { if (this->md5_set_) { this->md5_.calculate(); @@ -226,6 +274,10 @@ void IDFOTABackend::abort() { // or not an update is in flight. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; + this->written_ = 0; +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + this->erased_end_ = 0; +#endif } } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 9dffd5429e..c991f896e8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -5,8 +5,18 @@ #include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" +#include #include +// esp_ota_resume() (IDF 5.4.2+, backported to 5.3.3) provides a no-erase OTA +// handle, letting write() block-erase 64 KiB ahead of the write cursor +// (~4x faster than the per-sector lazy erase of OTA_WITH_SEQUENTIAL_WRITES, +// used as fallback on older IDF). +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) || \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 3) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0)) +#define USE_OTA_BLOCK_ERASE_AHEAD +#endif + namespace esphome::ota { #ifdef USE_OTA_PARTITIONS @@ -54,6 +64,9 @@ class IDFOTABackend final { #endif private: +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + OTAResponseTypes erase_ahead_(size_t len); +#endif #ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY // Accept an image signed by any key the running app trusts (up to 3 blocks), // so rotation and backup keys work. Fails closed. Covers app and bootloader. @@ -62,7 +75,11 @@ class IDFOTABackend final { // 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_; + const esp_partition_t *partition_{nullptr}; + size_t written_{0}; // Bytes handed to esp_ota_write() +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + size_t erased_end_{0}; // Erased up to this partition offset; must stay >= written_ +#endif char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 57b5529350..5a83d92689 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "ota_backend_esp_idf.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/defines.h" #ifdef USE_OTA_PARTITIONS @@ -69,12 +70,20 @@ OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() { return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; } // Erase full size of the bootloader partition in the staging partition - // to avoid copying old data to the bootloader partition later + // to avoid copying old data to the bootloader partition later. Up to + // ESP_BOOTLOADER_SIZE of blocking erase; widen the WDT for its duration. + watchdog::WatchdogManager watchdog(15000); esp_err_t err = esp_partition_erase_range(this->partition_, 0, this->bootloader_part_->size); if (err != ESP_OK) { ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err); // No critical error, don't return } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + if (err == ESP_OK) { + // Skip re-erasing the pre-erased staging region in erase_ahead_() + this->erased_end_ = this->bootloader_part_->size; + } +#endif err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false); if (err != ESP_OK) { esp_ota_abort(this->update_handle_); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 71dcc0eb83..501d6ac241 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -211,7 +211,7 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // Verification re-hashes the full image (after esp_ota_end already did one // pass), which can approach the task WDT budget on a large app. Extend it for - // the duration, mirroring the erase budget in begin(). + // the duration, scaled to the image size over a 15 s floor. const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10; watchdog::WatchdogManager watchdog(verify_budget_ms); diff --git a/tests/components/ota/test_erase_ahead.cpp b/tests/components/ota/test_erase_ahead.cpp new file mode 100644 index 0000000000..f84dd8a85d --- /dev/null +++ b/tests/components/ota/test_erase_ahead.cpp @@ -0,0 +1,41 @@ +// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the +// erased watermark must always cover the write end, stay 64 KiB block-aligned +// until the clamp, and never exceed the partition. + +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +static constexpr size_t BLOCK = 64 * 1024; +static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size + +TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); } + +TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); } + +TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); } + +TEST(NextEraseEnd, ClampsToPartitionEnd) { + // Partition sizes are sector multiples but not always block multiples + constexpr size_t part = 27 * BLOCK + 4096; + EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part); + EXPECT_EQ(next_erase_end(part, part), part); +} + +// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for +// a write past that seed must still cover the write end. +TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); } + +TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) { + for (size_t end = 1; end <= PART; end += 4093) { + const size_t erased = next_erase_end(end, PART); + ASSERT_GE(erased, end); + ASSERT_LE(erased, PART); + // Block-aligned unless clamped at the partition end + ASSERT_TRUE(erased == PART || erased % BLOCK == 0); + } +} + +} // namespace esphome::ota::testing From cf31c08a5cc0b92c667966bf5abc55e9c0b502e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:05:04 -0500 Subject: [PATCH 254/470] [core] Skip copying entity automation and filter sources when unused (#18602) --- esphome/components/binary_sensor/__init__.py | 18 +++++++++ .../components/binary_sensor/automation.cpp | 12 ++++++ esphome/components/esp32/__init__.py | 8 ++++ esphome/components/esp32/gpio.cpp | 7 +++- esphome/components/esp32/gpio.py | 1 + esphome/components/ota/__init__.py | 38 +++++++++---------- esphome/components/sensor/__init__.py | 6 +++ esphome/components/text_sensor/__init__.py | 6 +++ esphome/components/uptime/sensor/__init__.py | 11 ++---- esphome/config_helpers.py | 25 ++++++++++++ esphome/core/defines.h | 3 ++ tests/components/binary_sensor/common.yaml | 16 ++++++++ tests/unit_tests/test_config_helpers.py | 24 ++++++++++++ 13 files changed, 145 insertions(+), 30 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 5800e0bd9e..1ab6f7103f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DELAY, @@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = ( async def _build_binary_sensor_automations(var, config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK): + cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER") + if config.get(CONF_ON_MULTI_CLICK): + cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER") + for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH] @@ -673,3 +679,15 @@ async def to_code(config): async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) + + +# automation.cpp only implements the click/double_click/multi_click triggers +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "automation.cpp": ( + "USE_BINARY_SENSOR_CLICK_TRIGGER", + "USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER", + ), + "filter.cpp": "USE_BINARY_SENSOR_FILTER", + } +) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index b13e4a88dd..1a3c1f7536 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -1,8 +1,13 @@ +#include "esphome/core/defines.h" +#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER) + #include "automation.h" #include "esphome/core/log.h" namespace esphome::binary_sensor { +#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + static const char *const TAG = "binary_sensor.automation"; // MultiClickTrigger timeout IDs. @@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() { this->trigger(); } +#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + +#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { if (max_length == 0) { return length >= min_length; @@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { return length >= min_length && length <= max_length; } } +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER + } // namespace esphome::binary_sensor + +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 501c2e525f..cde0cfd68b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -12,6 +12,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -3451,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state + + +# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which +# are instantiated solely by the pin schema codegen (esp32_pin_to_code) +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} +) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 4b53d3a172..74665f3126 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,4 +1,7 @@ -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// Also defines the core ISRInternalGPIOPin methods; those are only reachable +// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. +#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) #include "gpio.h" #include "esphome/core/log.h" @@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 321dd3d498..98aac209ec 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): + cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 5240db9e8f..a2e6953a16 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,6 +1,9 @@ from esphome import automation import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, @@ -171,24 +174,17 @@ _filter_backend_source_files = filter_source_files_from_platform( ) +# USE_OTA_SIGNED_VERIFICATION_MULTI_KEY is set only on ESP32/IDF; +# USE_OTA_PARTITIONS is set by the esphome OTA platform when +# allow_partition_access is enabled. +_filter_define_source_files = filter_source_files_from_defines( + { + "ota_signature_esp_idf.cpp": "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY", + "ota_bootloader_esp_idf.cpp": "USE_OTA_PARTITIONS", + "ota_partitions_esp_idf.cpp": "USE_OTA_PARTITIONS", + } +) + + def FILTER_SOURCE_FILES() -> list[str]: - files = _filter_backend_source_files() - # ota_signature_esp_idf.cpp implements multi-key OTA signature verification, - # compiled only when the esp32 component enables it (external RSA signed - # OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on - # ESP32/IDF, so this also excludes the file on every other platform. Filter - # it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened - # and parsed on every build. - if not any( - define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" - for define in CORE.defines - ): - files.append("ota_signature_esp_idf.cpp") - # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully - # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when - # allow_partition_access is enabled). Filter them out otherwise for the - # same reason as above. - if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): - files.append("ota_bootloader_esp_idf.cpp") - files.append("ota_partitions_esp_idf.cpp") - return files + return _filter_backend_source_files() + _filter_define_source_files() diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6ad76046a1..79d4ce5e0c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -5,6 +5,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_B_CONSTANT +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ABOVE, @@ -1303,3 +1304,8 @@ def _lstsq(a, b): @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(sensor_ns.using) + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"filter.cpp": "USE_SENSOR_FILTER"} +) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index a3f4999a8f..29399a51b7 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_CLASS, @@ -256,3 +257,8 @@ async def text_sensor_state_to_code(config, condition_id, template_arg, args): templ = await cg.templatable(config[CONF_STATE], args, cg.std_string) cg.add(var.set_state(templ)) return var + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"filter.cpp": "USE_TEXT_SENSOR_FILTER"} +) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index debeb41444..dd76bb5a87 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, time +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, @@ -10,7 +11,6 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) -from esphome.core import CORE uptime_ns = cg.esphome_ns.namespace("uptime") UptimeSecondsSensor = uptime_ns.class_( @@ -62,9 +62,6 @@ async def to_code(config): cg.add(var.set_time(time_id)) -def FILTER_SOURCE_FILES() -> list[str]: - # uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it - # when no time component is configured. - if not any(define.name == "USE_TIME" for define in CORE.defines): - return ["uptime_timestamp_sensor.cpp"] - return [] +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"uptime_timestamp_sensor.cpp": "USE_TIME"} +) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c82c2b3dbe..60bed1537e 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -151,6 +151,31 @@ def filter_source_files_from_platform( return filter_source_files +def filter_source_files_from_defines( + files_map: dict[str, str | tuple[str, ...]], +) -> Callable[[], list[str]]: + """Helper to build a FILTER_SOURCE_FILES function from a define mapping. + + Args: + files_map: Dict mapping filename to the define name (or tuple of + define names) that keeps the file in the build; the file is + excluded when none of its defines is set for the current config. + + Returns: + Function that returns the files to exclude for the current config. + """ + + def filter_source_files() -> list[str]: + defines = {define.name for define in CORE.defines} + return [ + filename + for filename, needed in files_map.items() + if defines.isdisjoint((needed,) if isinstance(needed, str) else needed) + ] + + return filter_source_files + + def get_logger_level() -> str: """Get the configured logger level. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bb4960aec7..20aca3776f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -43,7 +43,9 @@ #define USE_ALARM_CONTROL_PANEL #define USE_AREAS #define USE_BINARY_SENSOR +#define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER +#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BUTTON #define USE_CAMERA @@ -281,6 +283,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..d0a16cc99c 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -136,3 +136,19 @@ binary_sensor: invalid_cooldown: 2s then: - logger.log: "Click with custom cooldown" + + # Test on_click and on_double_click (compiles match_interval via + # USE_BINARY_SENSOR_CLICK_TRIGGER) + - platform: template + id: click_triggers + name: "Click Triggers" + on_click: + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Clicked" + on_double_click: + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Double clicked" diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 88913c0f23..e53016dfc3 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from esphome.config_helpers import ( + filter_source_files_from_defines, filter_source_files_from_platform, frameworks_for_platforms, get_logger_level, @@ -18,6 +19,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PlatformFramework, ) +from esphome.core import Define def test_filter_source_files_from_platform_esp32() -> None: @@ -148,3 +150,25 @@ def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: } with pytest.raises(ValueError, match="unknown platform"): frameworks_for_platforms(["esp32", "not_a_platform"]) + + +def test_filter_source_files_from_defines() -> None: + """Files are excluded unless one of their defines is set.""" + files_map: dict[str, str | tuple[str, ...]] = { + "filter.cpp": "USE_SENSOR_FILTER", + "automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"), + } + filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map) + + with patch("esphome.config_helpers.CORE") as mock_core: + mock_core.defines = {Define("USE_SENSOR_FILTER")} + assert filter_func() == ["automation.cpp"] + + mock_core.defines = {Define("USE_MULTI_CLICK")} + assert filter_func() == ["filter.cpp"] + + mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")} + assert filter_func() == [] + + mock_core.defines = set() + assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"] From f741c274d577f748afc31b9156b1daecca9392cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:55:42 +0000 Subject: [PATCH 255/470] Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3362e43239..822eebc1f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.13.1 +aioesphomeapi==46.0.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 4efd30834575606ffc878549d7c4626c79e5eba4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 11:26:55 -0500 Subject: [PATCH 256/470] [tests] Fix flaky pty log probe test on macOS (#18681) --- tests/unit_tests/test_log.py | 48 +++++++++++++++++------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 194b38209b..40e3aa6d22 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,5 +1,4 @@ from collections.abc import Generator -import errno import io import logging import os @@ -178,37 +177,34 @@ def _run_probe_on_pty( output = b"" deadline = time.monotonic() + 60 try: - try: - proc = subprocess.Popen( - _probe_command(fixture_path), - stdout=follower, - stderr=follower if stderr_to_pty else subprocess.PIPE, - stdin=follower, - env=probe_env, - ) - finally: - os.close(follower) - while True: - timeout = deadline - time.monotonic() - if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: - pytest.fail(f"pty probe produced no EOF in time; got {output!r}") - try: - chunk = os.read(controller, 1024) - except OSError as err: - # macOS raises EIO once the child closes its end of the pty; - # anything else is a real failure, not end-of-stream. - if err.errno != errno.EIO: - raise - break - if not chunk: - break + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + # The parent keeps the follower open until the child has exited and + # the controller is drained: macOS discards buffered pty output once + # the last follower closes, so closing it early loses the probe's + # output whenever the child finishes before the first read. + while proc.poll() is None: + if time.monotonic() > deadline: + pytest.fail(f"pty probe did not exit in time; got {output!r}") + if select.select([controller], [], [], 0.01)[0]: + output += os.read(controller, 4096) + # Everything the child wrote is already buffered, so drain without waiting. + while select.select([controller], [], [], 0)[0] and ( + chunk := os.read(controller, 4096) + ): output += chunk stderr_text = "" if proc.stderr is not None: stderr_text = proc.stderr.read().decode(errors="replace") proc.stderr.close() - assert proc.wait(60) == 0, stderr_text + assert proc.returncode == 0, stderr_text finally: + os.close(follower) os.close(controller) if proc is not None and proc.poll() is None: proc.kill() From dde6906f980ecff7dedf08f289edf97b6e9ef5ba Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 23 Aug 2026 11:01:34 -0700 Subject: [PATCH 257/470] [modbus_client] Add continuous option to the read and send actions (#18542) Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 79 ++++++++++++++++++- esphome/components/modbus/modbus.cpp | 23 +++--- esphome/components/modbus/modbus.h | 48 +++++++---- esphome/components/modbus_client/__init__.py | 70 +++++++++++++--- .../components/modbus_client/modbus_client.h | 40 ++++++++-- .../modbus_client/test_modbus_client.py | 31 +++++++- .../modbus/modbus_client_hub_test.cpp | 40 +++++----- tests/components/modbus_client/common.yaml | 5 ++ 8 files changed, 261 insertions(+), 75 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a98591c6bc..89ffc7facf 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,17 +1,23 @@ from __future__ import annotations import logging -from typing import Any, Literal +from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_CONTINUOUS, + CONF_DISABLE_CRC, + CONF_FLOW_CONTROL_PIN, + CONF_ID, +) from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv -from esphome.types import ConfigType +from esphome.types import ConfigType, TemplateArgsType _LOGGER = logging.getLogger(__name__) @@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] + +class _CommandOption(NamedTuple): + """One per-command option forwarded to the hub (modbus::CommandOptions).""" + + conf_key: str + field: str # the C++ field, and so the set_() setter name + validator: Any # the static (non-templatable) validator for the key + cpp_type: Any # the C++ type the value is generated as + default: Any + + +# Per-direction command options. Single-sourcing the schema and the setter generation here keeps +# them from drifting; the C++ side must add the matching field per the rules documented on +# CommandOptions (modbus.h). +_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { + "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], + "write": [], +} + + +def _command_options(direction: str) -> list[_CommandOption]: + try: + return _COMMAND_OPTIONS[direction] + except KeyError: + raise ValueError(f"unknown command-options direction {direction!r}") from None + + +def command_options_schema( + *, direction: Literal["read", "write"], templatable: bool = False +) -> dict[cv.Optional, Any]: + """Schema fragment for the per-command options a component forwards to the hub + (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are + direction-specific so a schema never offers an option the hub would strip (e.g. + continuous on a write); the write side has no options yet. For actions (templatable=True the + keys also accept lambdas), register the values with register_templatable_command_options(). + """ + return { + cv.Optional(option.conf_key, default=option.default): ( + cv.templatable(option.validator) if templatable else option.validator + ) + for option in _command_options(direction) + } + + +async def register_templatable_command_options( + var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str +) -> None: + """Generate the set_